From 73e8701c0c5bbe46b7e7966501f625cd4883c2d1 Mon Sep 17 00:00:00 2001 From: Konrad Mohrfeldt <km@roko.li> Date: Thu, 23 Jan 2025 21:06:20 +0100 Subject: [PATCH] refactor: rework media management This refactoring includes some fixes, new features, and updates to the API: * the new /media and /media-sources APIs are used * users can now cancel the addition of new media sources * all errors should now be catched and displayed, no matter when in the handling process they happen * users can show the import logs for uploads * uploads happen in the background so users can move to other pages without aborting the upload refs #347 refs #328 refs #323 refs #342 refs #273 --- src/Pages/Calendar.vue | 12 +- src/Pages/ShowBasicData.vue | 27 +- src/Pages/ShowEpisode.vue | 8 +- src/Pages/ShowEpisodeDetails.vue | 24 +- src/components/calendar/ACalendarDayEntry.vue | 14 +- src/components/episode/AEpisodeTableRow.vue | 10 +- src/components/generic/AFragment.vue | 22 + .../{playlist => media}/AFileImportLog.vue | 0 .../{playlist => media}/AFileUrlDialog.vue | 4 +- .../{playlist => media}/AInputUrlDialog.vue | 4 +- .../{playlist => media}/AM3uUrlDialog.vue | 4 +- .../AMediaDurationCheck.vue} | 16 +- src/components/media/AMediaEditor.vue | 195 +++++++ .../AMediaSourceEditor.vue} | 79 +-- src/components/media/AMediaSourceJob.vue | 170 ++++++ src/components/media/AMediaSourceJobQueue.vue | 23 + src/components/media/AMediaSources.vue | 72 +++ .../{playlist => media}/AStreamURLDialog.vue | 4 +- src/components/playlist/APlaylistEditor.vue | 384 -------------- src/components/playlist/APlaylistEntries.vue | 64 --- src/components/playlist/AUploadProgress.vue | 63 --- .../schedule/AScheduleCreateDialog.vue | 2 +- .../schedule/AScheduleEditDialog.vue | 4 +- src/components/shows/TimeSlotRow.vue | 16 +- src/i18n/de.js | 189 +++---- src/i18n/en.js | 188 +++---- src/steering-types.ts | 486 ++++++++++++------ src/stores/files.ts | 67 ++- src/stores/index.ts | 3 +- src/stores/media-manager.ts | 404 +++++++++++++++ src/stores/media-sources.ts | 41 ++ src/stores/media.ts | 105 ++++ src/stores/playlists.ts | 104 ---- src/stores/schedules.ts | 2 +- src/stores/shows.ts | 2 +- src/types.ts | 14 +- src/util/api.ts | 25 +- src/util/index.ts | 8 + tailwind.config.js | 1 + tests/shows.spec.ts | 4 +- 40 files changed, 1791 insertions(+), 1073 deletions(-) create mode 100644 src/components/generic/AFragment.vue rename src/components/{playlist => media}/AFileImportLog.vue (100%) rename src/components/{playlist => media}/AFileUrlDialog.vue (88%) rename src/components/{playlist => media}/AInputUrlDialog.vue (93%) rename src/components/{playlist => media}/AM3uUrlDialog.vue (89%) rename src/components/{playlist/APlaylistDurationCheck.vue => media/AMediaDurationCheck.vue} (50%) create mode 100644 src/components/media/AMediaEditor.vue rename src/components/{playlist/APlaylistEntryEditor.vue => media/AMediaSourceEditor.vue} (73%) create mode 100644 src/components/media/AMediaSourceJob.vue create mode 100644 src/components/media/AMediaSourceJobQueue.vue create mode 100644 src/components/media/AMediaSources.vue rename src/components/{playlist => media}/AStreamURLDialog.vue (88%) delete mode 100644 src/components/playlist/APlaylistEditor.vue delete mode 100644 src/components/playlist/APlaylistEntries.vue delete mode 100644 src/components/playlist/AUploadProgress.vue create mode 100644 src/stores/media-manager.ts create mode 100644 src/stores/media-sources.ts create mode 100644 src/stores/media.ts delete mode 100644 src/stores/playlists.ts diff --git a/src/Pages/Calendar.vue b/src/Pages/Calendar.vue index 00088094..3e1a4de8 100644 --- a/src/Pages/Calendar.vue +++ b/src/Pages/Calendar.vue @@ -101,7 +101,7 @@ import { useRoute, useRouter } from 'vue-router' import { useI18n } from '@/i18n' import { useAuthStore, - usePlaylistStore, + useMediaStore, useRRuleStore, useShowStore, useTimeSlotStore, @@ -137,7 +137,7 @@ const authStore = useAuthStore() const rruleStore = useRRuleStore() const showStore = useShowStore() const timeslotStore = useTimeSlotStore() -const playlistStore = usePlaylistStore() +const mediaStore = useMediaStore() const radioSettings = useCurrentRadioSettings() const canAddSchedule = useHasUserPermission(['program.add_schedule']) @@ -178,14 +178,14 @@ const calendarEvents = computedAsync( program.value.map((e) => e.timeslotId).filter((id) => id !== null) as number[], { useCached: true }, ), - playlistStore.retrieveMultiple( - program.value.map((e) => e.playlistId).filter((id) => id !== null) as number[], + mediaStore.retrieveMultiple( + program.value.map((e) => e.mediaId).filter((id) => id !== null) as number[], { useCached: true }, ), ]) for (const entry of program.value) { - const cacheKey = [entry.id, entry.timeslotId, entry.playlistId].join(':') + const cacheKey = [entry.id, entry.timeslotId, entry.mediaId].join(':') const cachedSlot = calendarEventsCache.get(cacheKey) if (cachedSlot) { @@ -193,7 +193,7 @@ const calendarEvents = computedAsync( continue } - const isEmpty = entry.timeslotId && entry.playlistId === null + const isEmpty = entry.timeslotId && entry.mediaId === null const emptyText = isEmpty ? `: ${t('calendar.empty')} ⚠` : '' const ts = entry.timeslotId ? await timeslotStore.retrieve(entry.timeslotId, { useCached: true }) diff --git a/src/Pages/ShowBasicData.vue b/src/Pages/ShowBasicData.vue index 7cfcfaed..edc83a04 100644 --- a/src/Pages/ShowBasicData.vue +++ b/src/Pages/ShowBasicData.vue @@ -330,17 +330,14 @@ v-if="radioSettings?.program?.fallback?.showId !== show.id" :title="t('show.section.media.title')" > - <FormGroup - v-slot="{ disabled }" - :errors="playlistId.errors" - edit-permissions="program.edit__show__default_playlist_id" - > - <APlaylistEditor - :playlist="playlist" + <FormGroup v-slot="{ disabled }" edit-permissions="program.edit__show__default_media_id"> + <AMediaEditor :show="show" + :media="media" + :get-media="getMedia" :disabled="disabled" + :context-key="`show-${show.id}-default-media`" class="tw-max-w-3xl" - @create="playlistId.value = $event.id" /> </FormGroup> </AFieldset> @@ -378,7 +375,7 @@ import { useProfileStore, useLanguageStore, useMusicFocusStore, - usePlaylistStore, + useMediaStore, useShowStore, useTopicStore, useTypeStore, @@ -400,10 +397,11 @@ import ComboBoxSimple from '@/components/ComboBoxSimple.vue' import Tag from '@/components/generic/Tag.vue' import ImagePicker from '@/components/images/ImagePicker.vue' import AFieldset from '@/components/generic/AFieldset.vue' -import APlaylistEditor from '@/components/playlist/APlaylistEditor.vue' +import AMediaEditor from '@/components/media/AMediaEditor.vue' import AUserSelector from '@/components/identities/AUserSelector.vue' import AProfileSelector from '@/components/identities/AProfileSelector.vue' import { useCurrentRadioSettings, useImageRequirements } from '@/stores/radio-settings' +import { useMediaFactory } from '@/stores/media-manager' const props = defineProps<{ show: Show @@ -419,7 +417,7 @@ const musicFocusStore = useMusicFocusStore() const languageStore = useLanguageStore() const profileStore = useProfileStore() const fundingCategoryStore = useFundingCategoryStore() -const playlistStore = usePlaylistStore() +const mediaStore = useMediaStore() const radioSettings = useCurrentRadioSettings() const show = computed(() => props.show) @@ -444,11 +442,14 @@ const owners = useRelationList(showStore, () => props.show, 'ownerIds', userStor sortBy: ['lastName', 'firstName', 'username', 'email'], }) -const { obj: playlist } = useObjectFromStore(() => props.show.defaultPlaylistId, playlistStore) -const playlistId = useAPIObjectFieldCopy(showStore, show, 'defaultPlaylistId', { debounce: 0 }) +const { obj: media } = useObjectFromStore(() => props.show.defaultMediaId, mediaStore) const logoRequirements = useImageRequirements('show.logo') const imageRequirements = useImageRequirements('show.image') +const getMedia = useMediaFactory(show, media, (media) => + showStore.partialUpdate(props.show.id, { defaultMediaId: media.id }), +) + useBreadcrumbs(() => [ { title: t('navigation.shows'), route: { name: 'shows' } }, { title: props.show.name, route: { name: 'show', params: { showId: props.show.id.toString() } } }, diff --git a/src/Pages/ShowEpisode.vue b/src/Pages/ShowEpisode.vue index 1ce47a9c..f94f078f 100644 --- a/src/Pages/ShowEpisode.vue +++ b/src/Pages/ShowEpisode.vue @@ -1,5 +1,5 @@ <template> - <router-view v-if="episode" :show="show" :episode="episode" :playlist="playlist" /> + <router-view v-if="episode" :show="show" :episode="episode" :media="media" /> </template> <script setup lang="ts"> @@ -7,7 +7,7 @@ import { watchEffect } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useObjectFromStore } from '@rokoli/bnb/drf' -import { useEpisodeStore, usePlaylistStore } from '@/stores' +import { useEpisodeStore, useMediaStore } from '@/stores' import { defineNavigationContext } from '@/stores/nav' import { Show } from '@/types' @@ -18,12 +18,12 @@ const props = defineProps<{ const router = useRouter() const route = useRoute() const episodeStore = useEpisodeStore() -const playlistStore = usePlaylistStore() +const mediaStore = useMediaStore() const { obj: episode } = useObjectFromStore( () => parseInt(route.params.episodeId as string), episodeStore, ) -const { obj: playlist } = useObjectFromStore(() => episode.value?.mediaId ?? null, playlistStore) +const { obj: media } = useObjectFromStore(() => episode.value?.mediaId ?? null, mediaStore) defineNavigationContext(() => ({ episode })) diff --git a/src/Pages/ShowEpisodeDetails.vue b/src/Pages/ShowEpisodeDetails.vue index f09cfd7a..10c66371 100644 --- a/src/Pages/ShowEpisodeDetails.vue +++ b/src/Pages/ShowEpisodeDetails.vue @@ -20,13 +20,14 @@ <p v-else>{{ t('timeslot.labels.noneAssigned') }}</p> </AFieldset> - <AFieldset :title="t('playlist.editor.title')" class="tw-col-span-full xl:tw-col-span-5"> + <AFieldset :title="t('media.editor.title')" class="tw-col-span-full xl:tw-col-span-5"> <APermissionGuard v-slot="{ disabled }" edit-permissions="program.edit__episode__media"> - <APlaylistEditor - :playlist="playlist" + <AMediaEditor :show="show" + :media="media" + :get-media="getMedia" :disabled="disabled" - @create="assignPlaylist" + :context-key="`episode-${episode.id}-media`" /> </APermissionGuard> </AFieldset> @@ -53,20 +54,21 @@ import { computed } from 'vue' import { useI18n } from '@/i18n' import { useEpisodeStore, useTimeSlotStore } from '@/stores' import { useBreadcrumbs } from '@/stores/nav' -import { Episode, Playlist, Show } from '@/types' +import { Episode, Media, Show } from '@/types' import PageHeader from '@/components/PageHeader.vue' import EpisodeDescriptionEditor from '@/components/episode/EpisodeDescriptionEditor.vue' import ATimeEditInfo from '@/components/generic/ATimeEditInfo.vue' import APermissionGuard from '@/components/generic/APermissionGuard.vue' -import APlaylistEditor from '@/components/playlist/APlaylistEditor.vue' +import AMediaEditor from '@/components/media/AMediaEditor.vue' import AFieldset from '@/components/generic/AFieldset.vue' import { ensureDate, useObjectListFromStore } from '@/util' import ASpinnerLabel from '@/components/generic/ASpinnerLabel.vue' +import { useMediaFactory } from '@/stores/media-manager' const props = defineProps<{ show: Show episode: Episode - playlist: Playlist | null + media: Media | null }>() const { locale, t } = useI18n() @@ -92,9 +94,11 @@ const timeslotData = computed(() => { }) }) -async function assignPlaylist(playlist: Playlist) { - await episodeStore.partialUpdate(props.episode.id, { mediaId: playlist.id }) -} +const getMedia = useMediaFactory( + () => props.show, + () => props.media, + (media) => episodeStore.partialUpdate(props.episode.id, { mediaId: media.id }), +) useBreadcrumbs(() => [ { title: t('navigation.shows'), route: { name: 'shows' } }, diff --git a/src/components/calendar/ACalendarDayEntry.vue b/src/components/calendar/ACalendarDayEntry.vue index 1410d0d7..d74b7b44 100644 --- a/src/components/calendar/ACalendarDayEntry.vue +++ b/src/components/calendar/ACalendarDayEntry.vue @@ -83,8 +83,8 @@ <APill v-if=" - playlistDurationInSeconds !== null && - timeslotDurationInSeconds !== playlistDurationInSeconds + mediaDurationInSeconds !== null && + timeslotDurationInSeconds !== mediaDurationInSeconds " class="tw-text-amber-700 tw-text-sm" > @@ -110,7 +110,7 @@ import SafeHTML from '@/components/generic/SafeHTML' import ATimelineHeader from '@/components/generic/ATimelineHeader.vue' import ATimelineIcon from '@/components/generic/ATimelineIcon.vue' import { ProgramEntry, TimeSlot } from '@/types' -import { calculatePlaylistDurationInSeconds, usePlaylistStore } from '@/stores/playlists' +import { calculateMediaDurationInSeconds, useMediaStore } from '@/stores/media' import { useObjectFromStore } from '@rokoli/bnb/drf' import { useEpisodeStore, useShowStore, useTimeSlotStore } from '@/stores' import { computed } from 'vue' @@ -129,7 +129,7 @@ const emit = defineEmits<{ edit: [TimeSlot] }>() const { locale, t } = useI18n() const showStore = useShowStore() const timeslotStore = useTimeSlotStore() -const playlistStore = usePlaylistStore() +const mediaStore = useMediaStore() const episodeStore = useEpisodeStore() const start = computed(() => ensureDate(props.entry.start)) @@ -138,9 +138,9 @@ const durationInSeconds = computed(() => calculateDurationSeconds(start.value, e const { obj: show } = useObjectFromStore(() => props.entry.showId, showStore) -const { obj: playlist } = useObjectFromStore(() => props.entry.playlistId, playlistStore) -const playlistDurationInSeconds = computed(() => - playlist.value ? calculatePlaylistDurationInSeconds(playlist.value) : null, +const { obj: media } = useObjectFromStore(() => props.entry.mediaId, mediaStore) +const mediaDurationInSeconds = computed(() => + media.value ? calculateMediaDurationInSeconds(media.value) : null, ) const { obj: timeslot } = useObjectFromStore(() => props.entry.timeslotId, timeslotStore) diff --git a/src/components/episode/AEpisodeTableRow.vue b/src/components/episode/AEpisodeTableRow.vue index 153d17cb..211e6294 100644 --- a/src/components/episode/AEpisodeTableRow.vue +++ b/src/components/episode/AEpisodeTableRow.vue @@ -15,8 +15,8 @@ </span> </td> <td class="tw-text-xs"> - <AStatus v-if="playlist && playlist.entries.length > 0" is-success rounded> - {{ t('mediaSource.count', { smart_count: playlist.entries.length }) }} + <AStatus v-if="media && media.entries.length > 0" is-success rounded> + {{ t('mediaSource.count', { smart_count: media.entries.length }) }} </AStatus> <AStatus v-else is-warning rounded> {{ t('labels.missing') }} @@ -31,7 +31,7 @@ import { computed, useAttrs } from 'vue' import { useI18n } from '@/i18n' import { Episode } from '@/types' -import { usePlaylistStore, useTimeSlotStore } from '@/stores' +import { useMediaStore, useTimeSlotStore } from '@/stores' import Loading from '@/components/generic/Loading.vue' import { ensureDate, useObjectListFromStore } from '@/util' import AStatus from '@/components/generic/AStatus.vue' @@ -47,10 +47,10 @@ const props = defineProps<{ const attrs = useAttrs() const { t, locale } = useI18n() -const playlistStore = usePlaylistStore() +const mediaStore = useMediaStore() const timeslotStore = useTimeSlotStore() -const { obj: playlist } = useObjectFromStore(() => props.episode.mediaId, playlistStore) +const { obj: media } = useObjectFromStore(() => props.episode.mediaId, mediaStore) const { objects: timeslots, isLoading: isLoadingTimeslots } = useObjectListFromStore( () => props.episode.timeslotIds as number[], timeslotStore, diff --git a/src/components/generic/AFragment.vue b/src/components/generic/AFragment.vue new file mode 100644 index 00000000..3a395d32 --- /dev/null +++ b/src/components/generic/AFragment.vue @@ -0,0 +1,22 @@ +<script lang="ts" setup> +import type { Component } from 'vue' + +import { useAttrs } from 'vue' + +defineOptions({ + inheritAttrs: false, +}) +defineProps<{ + wrapIn?: string | Component | undefined +}>() +const attrs = useAttrs() +</script> + +<template> + <component :is="wrapIn" v-if="wrapIn" v-bind="attrs"> + <slot /> + </component> + <template v-else> + <slot /> + </template> +</template> diff --git a/src/components/playlist/AFileImportLog.vue b/src/components/media/AFileImportLog.vue similarity index 100% rename from src/components/playlist/AFileImportLog.vue rename to src/components/media/AFileImportLog.vue diff --git a/src/components/playlist/AFileUrlDialog.vue b/src/components/media/AFileUrlDialog.vue similarity index 88% rename from src/components/playlist/AFileUrlDialog.vue rename to src/components/media/AFileUrlDialog.vue index 19ffd7b7..cbd929c7 100644 --- a/src/components/playlist/AFileUrlDialog.vue +++ b/src/components/media/AFileUrlDialog.vue @@ -1,8 +1,8 @@ <template> <AEditDialog ref="dialog" - :title="t('playlist.editor.importFileDialog.title')" - :save-label="t('playlist.editor.importFileDialog.saveLabel')" + :title="t('media.editor.importFileDialog.title')" + :save-label="t('media.editor.importFileDialog.saveLabel')" :can-save="canSave" :save="() => emit('save', url)" class="md:tw-w-[600px]" diff --git a/src/components/playlist/AInputUrlDialog.vue b/src/components/media/AInputUrlDialog.vue similarity index 93% rename from src/components/playlist/AInputUrlDialog.vue rename to src/components/media/AInputUrlDialog.vue index 50ede69b..939458a1 100644 --- a/src/components/playlist/AInputUrlDialog.vue +++ b/src/components/media/AInputUrlDialog.vue @@ -1,9 +1,9 @@ <template> <AEditDialog ref="dialog" - :title="t('playlist.editor.addInputDialog.title')" + :title="t('media.editor.addInputDialog.title')" :can-save="canSave" - :save-label="t('playlist.editor.addInputDialog.saveLabel')" + :save-label="t('media.editor.addInputDialog.saveLabel')" :save="() => emit('save', selectedInput as string)" class="tw-w-min" > diff --git a/src/components/playlist/AM3uUrlDialog.vue b/src/components/media/AM3uUrlDialog.vue similarity index 89% rename from src/components/playlist/AM3uUrlDialog.vue rename to src/components/media/AM3uUrlDialog.vue index d92fc94a..e465f606 100644 --- a/src/components/playlist/AM3uUrlDialog.vue +++ b/src/components/media/AM3uUrlDialog.vue @@ -1,8 +1,8 @@ <template> <AEditDialog ref="dialog" - :title="t('playlist.editor.addM3uDialog.title')" - :save-label="t('playlist.editor.addM3uDialog.saveLabel')" + :title="t('media.editor.addM3uDialog.title')" + :save-label="t('media.editor.addM3uDialog.saveLabel')" :can-save="canSave" :save="() => emit('save', url)" class="md:tw-w-[600px]" diff --git a/src/components/playlist/APlaylistDurationCheck.vue b/src/components/media/AMediaDurationCheck.vue similarity index 50% rename from src/components/playlist/APlaylistDurationCheck.vue rename to src/components/media/AMediaDurationCheck.vue index 5b487599..307e864d 100644 --- a/src/components/playlist/APlaylistDurationCheck.vue +++ b/src/components/media/AMediaDurationCheck.vue @@ -1,9 +1,9 @@ <template> - <div v-if="playlistState.state !== 'ok' && playlistState.state !== 'missing'"> - <AAlert :title="t(`playlist.state.${playlistState.state}.title`)" is-warning> + <div v-if="mediaState.state !== 'ok' && mediaState.state !== 'missing'"> + <AAlert :title="t(`media.state.${mediaState.state}.title`)" is-warning> <SafeHTML sanitize-preset="inline-noninteractive" - :html="t(`playlist.state.${playlistState.state}.description`, { totalTime, offset })" + :html="t(`media.state.${mediaState.state}.description`, { totalTime, offset })" /> </AAlert> </div> @@ -13,20 +13,20 @@ import { computed } from 'vue' import { useI18n } from '@/i18n' -import { usePlaylistState } from '@/stores/playlists' -import { Playlist } from '@/types' +import { useMediaState } from '@/stores/media' +import { Media } from '@/types' import { secondsToDurationString } from '@/util' import AAlert from '@/components/generic/AAlert.vue' import SafeHTML from '@/components/generic/SafeHTML' const props = defineProps<{ requiredDurationSeconds: number - playlist: Playlist + media: Media }>() const { t } = useI18n() const totalTime = computed(() => secondsToDurationString(props.requiredDurationSeconds)) -const playlistState = usePlaylistState(() => props.playlist, props.requiredDurationSeconds) -const offset = computed(() => secondsToDurationString(playlistState.value?.offset ?? 0)) +const mediaState = useMediaState(() => props.media, props.requiredDurationSeconds) +const offset = computed(() => secondsToDurationString(mediaState.value?.offset ?? 0)) </script> diff --git a/src/components/media/AMediaEditor.vue b/src/components/media/AMediaEditor.vue new file mode 100644 index 00000000..ec9f12b0 --- /dev/null +++ b/src/components/media/AMediaEditor.vue @@ -0,0 +1,195 @@ +<template> + <div class="tw-relative" v-bind="attrs"> + <AMediaDurationCheck + v-if="media && media.entries.length > 0 && requiredDurationSeconds > 0" + :media="media" + :required-duration-seconds="requiredDurationSeconds" + class="tw-mb-6" + /> + + <AMediaSources + v-if="sources.length > 0" + :sources="sources" + class="tw-mb-6" + :can-sort="!disabled" + :can-edit="!disabled" + /> + + <p v-if="disabled && sources.length === 0" class="tw-m-0"> + {{ t('media.editor.noEntries') }} + </p> + + <section class="tw-mb-6 empty:tw-hidden"> + <AMediaSourceJobQueue :context-key="contextKey" :label="t('media.editor.upload.pending')" /> + </section> + + <fieldset + v-if="!disabled" + ref="dropzoneEl" + class="tw-rounded tw-border-2 tw-flex tw-mb-3 tw-p-6 dark:tw-border-neutral-700" + :class="{ + 'tw-border-teal-600': isOverDropZone && isAllowedToAddFiles, + 'tw-border-gray-200': !isOverDropZone, + 'tw-border-dashed': isAllowedToAddFiles, + }" + > + <div class="tw-place-self-center tw-mx-auto tw-flex tw-flex-col tw-gap-2 tw-items-center"> + <template v-if="isAllowedToAddFiles"> + <icon-system-uicons-file-upload class="tw-text-xl" /> + <p class="tw-mb-0">{{ t('media.editor.control.dropFiles') }}</p> + <p class="tw-text-gray-400 tw-text-sm tw-mb-1.5 tw-leading-none"> + {{ t('media.editor.control._or') }} + </p> + </template> + <div class="tw-flex tw-flex-wrap tw-justify-center tw-items-center tw-gap-3"> + <button + v-if="isAllowedToAddFiles" + type="button" + class="btn btn-default" + data-testid="media-editor:open-file-dialog" + @click="openFileDialog()" + > + <icon-iconamoon-file-audio-thin class="tw-flex-none" /> + {{ t('media.editor.control.selectFiles') }} + </button> + <APermissionGuard show-permissions="program.add__import"> + <button type="button" class="btn btn-default" @click="importFileFromURL()"> + <icon-formkit-url class="tw-flex-none" /> + {{ t('media.editor.control.importFile') }} + </button> + <GetFileImportUrl v-slot="{ resolve }"> + <AFileUrlDialog @save="resolve($event)" @close="resolve(null)" /> + </GetFileImportUrl> + </APermissionGuard> + </div> + <div class="tw-flex tw-flex-wrap tw-justify-center tw-items-center tw-gap-3 tw-mt-1"> + <APermissionGuard show-permissions="program.add__stream"> + <button type="button" class="btn btn-default" @click="addStreamMediaSource()"> + <icon-solar-play-stream-bold class="tw-flex-none" /> + {{ t('media.editor.control.addStream') }} + </button> + <GetStreamUrl v-slot="{ resolve }"> + <AStreamURLDialog @save="resolve($event)" @close="resolve(null)" /> + </GetStreamUrl> + </APermissionGuard> + <APermissionGuard show-permissions="program.add__line"> + <button type="button" class="btn btn-default" @click="addInputMediaSource"> + <icon-game-icons-jack-plug class="tw-flex-none" /> + {{ t('media.editor.control.addInput') }} + </button> + <GetInputUrl v-slot="{ resolve }"> + <AInputUrlDialog @save="resolve($event)" @close="resolve(null)" /> + </GetInputUrl> + </APermissionGuard> + <APermissionGuard show-permissions="program.add__m3ufile"> + <button type="button" class="btn btn-default" @click="addM3UMediaSource"> + <icon-ph-playlist-light class="tw-flex-none" /> + {{ t('media.editor.control.addM3u') }} + </button> + <GetM3uUrl v-slot="{ resolve }"> + <AM3uUrlDialog @save="resolve($event)" @close="resolve(null)" /> + </GetM3uUrl> + </APermissionGuard> + </div> + </div> + </fieldset> + </div> +</template> + +<script lang="ts" setup> +import { createTemplatePromise, useDropZone, useFileDialog } from '@vueuse/core' +import { computed, ref, useAttrs, watch } from 'vue' + +import { useI18n } from '@/i18n' +import { Media, Show } from '@/types' + +import AStreamURLDialog from '@/components/media/AStreamURLDialog.vue' +import AFileUrlDialog from '@/components/media/AFileUrlDialog.vue' +import AInputUrlDialog from '@/components/media/AInputUrlDialog.vue' +import AM3uUrlDialog from '@/components/media/AM3uUrlDialog.vue' +import AMediaDurationCheck from '@/components/media/AMediaDurationCheck.vue' +import AMediaSources from '@/components/media/AMediaSources.vue' +import APermissionGuard from '@/components/generic/APermissionGuard.vue' +import { useHasUserPermission } from '@/stores/auth' +import { MediaResolver, useMediaSourceController } from '@/stores/media-manager' +import AMediaSourceJobQueue from '@/components/media/AMediaSourceJobQueue.vue' +import { usePlayoutStore } from '@/stores' + +const props = withDefaults( + defineProps<{ + show: Show + getMedia: MediaResolver + disabled?: boolean + media?: Media | null + contextKey?: string | null | undefined + requiredDurationSeconds?: number + }>(), + { + requiredDurationSeconds: -1, + contextKey: undefined, + media: null, + }, +) + +const attrs = useAttrs() +const { t } = useI18n() + +const sources = computed(() => props.media?.entries ?? []) + +const playoutStore = usePlayoutStore() +const mediaSourceManager = useMediaSourceController({ + show: () => props.show, + contextKey: () => props.contextKey, + getMedia: props.getMedia, +}) + +const isAllowedToAddFiles = useHasUserPermission(['program.add__file']) +function addFiles(files: File[] | FileList | null) { + if (isAllowedToAddFiles.value) { + mediaSourceManager.addFiles(files) + } +} + +// files handled through file dialog +const { open: openFileDialog, files } = useFileDialog({ accept: 'audio/*', multiple: true }) +watch(files, (files) => addFiles(files)) + +// files handled through dropzone +const dropzoneEl = ref<HTMLDivElement>() +const { isOverDropZone, files: dropzoneFiles } = useDropZone(dropzoneEl) +watch(dropzoneFiles, (files) => addFiles(files)) + +// files imported by URL +const GetFileImportUrl = createTemplatePromise<string | null>() +async function importFileFromURL() { + const fileUrl = await GetFileImportUrl.start() + if (!fileUrl) return + mediaSourceManager.addImports(fileUrl) +} + +// streams +const GetStreamUrl = createTemplatePromise<string | null>() +async function addStreamMediaSource() { + const streamURL = await GetStreamUrl.start() + if (!streamURL) return + mediaSourceManager.addUrls(streamURL) +} + +// inputs +const GetInputUrl = createTemplatePromise<string | null>() +async function addInputMediaSource() { + const url = await GetInputUrl.start() + if (!url) return + const input = playoutStore.inputs.find((input) => input.uri === url) + const name = input?.label ?? t('input.singular') + mediaSourceManager.addUrls({ url, name }) +} + +// M3U +const GetM3uUrl = createTemplatePromise<string | null>() +async function addM3UMediaSource() { + const m3uUrl = await GetM3uUrl.start() + if (!m3uUrl) return + mediaSourceManager.addUrls(m3uUrl) +} +</script> diff --git a/src/components/playlist/APlaylistEntryEditor.vue b/src/components/media/AMediaSourceEditor.vue similarity index 73% rename from src/components/playlist/APlaylistEntryEditor.vue rename to src/components/media/AMediaSourceEditor.vue index fd2b919b..4e01dfd0 100644 --- a/src/components/playlist/APlaylistEntryEditor.vue +++ b/src/components/media/AMediaSourceEditor.vue @@ -2,10 +2,10 @@ <component :is="as" class="tw-bg-gray-50 tw-border dark:tw-bg-neutral-800 dark:tw-border-neutral-700 tw-p-3 tw-rounded tw-flex tw-gap-2 tw-items-center" + :class="{ 'tw-opacity-50 tw-pointer-events-none': isDeleted }" > <div> <icon-system-uicons-drag-vertical - v-if="dragHandle" class="tw-flex-none tw-mr-2 tw-cursor-grab" data-drag-handle /> @@ -19,7 +19,7 @@ </template> <template v-else-if="type === 'file'"> <icon-iconamoon-file-audio-thin class="tw-flex-none" /> - <span class="tw-truncate" data-testid="playlist-entry-editor:file-title"> + <span class="tw-truncate tw-min-w-[50px]" data-testid="media-source-editor:file-title"> {{ file?.metadata?.title ?? t('file.unnamed') }} </span> <span @@ -28,23 +28,23 @@ tabindex="0" > <span - class="tw-rounded-full tw-px-3 tw-py-1 tw-border tw-border-solid tw-border-black/10 tw-inline-flex tw-items-center tw-gap-2 tw-cursor-help group-hocus:tw-bg-indigo-100 dark:group-hocus:tw-bg-indigo-800 group-hocus:tw-ring-2" + class="tw-flex tw-rounded-full tw-px-3 tw-py-1 tw-border tw-border-solid tw-border-black/10 tw-items-center tw-gap-2 tw-cursor-help group-hocus:tw-bg-indigo-100 dark:group-hocus:tw-bg-indigo-800 group-hocus:tw-ring-2" > - <icon-iconamoon-music-artist-thin /> - {{ t('file.metadata._title') }} + <icon-iconamoon-music-artist-thin class="tw-flex-none" /> + <span class="tw-sr-only">{{ t('file.metadata._title') }}</span> </span> <SafeHTML :html="artistInfo" sanitize-preset="inline-noninteractive" - class="tw-absolute tw-top-full tw-left-0 tw-mt-1 tw-p-3 tw-rounded tw-bg-white dark:tw-bg-neutral-800 tw-shadow-xl tw-z-10 tw-pointer-events-none tw-transition-all tw-opacity-0 -tw-translate-y-1 group-hocus:tw-opacity-100 group-hocus:tw-translate-y-0" + class="tw-absolute tw-top-full tw-left-0 tw-mt-1 tw-p-3 tw-min-w-32 tw-rounded tw-bg-white dark:tw-bg-neutral-800 tw-shadow-xl tw-z-10 tw-pointer-events-none tw-transition-all tw-opacity-0 -tw-translate-y-1 group-hocus:tw-opacity-100 group-hocus:tw-translate-y-0" /> </span> </template> <template v-else-if="['http', 'https'].includes(type)"> <icon-solar-play-stream-bold class="tw-flex-none" /> <span class="tw-truncate"> - {{ entry.uri }} + {{ mediaSource.uri }} </span> </template> <template v-else-if="type === 'm3u'"> @@ -56,8 +56,8 @@ </span> <span class="tw-ml-auto"> - <template v-if="typeof entry.duration === 'number'"> - {{ secondsToDurationString(entry.duration) }} + <template v-if="typeof mediaSource.duration === 'number'"> + {{ secondsToDurationString(mediaSource.duration) }} </template> <AStatus v-else class="tw-text-xs" rounded is-warning> {{ t('file.durationUnknown') }} @@ -66,7 +66,6 @@ <div> <button - v-if="canEdit" type="button" class="btn btn-default btn-sm tw-p-1 tw-rounded-full" @click="editDialog.open()" @@ -76,9 +75,8 @@ </div> <AEditDialog - v-if="canEdit" ref="editDialog" - :title="t('playlist.editor.editPlaylistEntry')" + :title="t('media.editor.editMediaSource')" :save="save" class="tw-w-[90vw] md:tw-w-min" > @@ -115,11 +113,11 @@ type="button" class="btn btn-danger tw-ml-3 md:tw-ml-12" :disabled="isSaving" - @click="emit('delete')" + @click="remove" > <icon-system-uicons-trash /> <span class="tw-hidden md:tw-block tw-whitespace-nowrap"> - {{ t('playlist.editor.deletePlaylistEntry') }} + {{ t('media.editor.deleteMediaSource') }} </span> </button> </div> @@ -137,7 +135,11 @@ </template> </AEditDialog> - <AFileImportLog v-if="entry.fileId" ref="fileImportLogDialog" :file-id="entry.fileId" /> + <AFileImportLog + v-if="mediaSource.fileId" + ref="fileImportLogDialog" + :file-id="mediaSource.fileId" + /> </component> </template> @@ -147,45 +149,45 @@ import { computed, ref } from 'vue' import { useCopy } from '@/form' import { useI18n } from '@/i18n' -import { useFilesStore } from '@/stores' +import { useFilesStore, useMediaSourceStore } from '@/stores' import { useInput } from '@/stores/playout' -import { FileMetadata, PlaylistEntry } from '@/types' +import { FileMetadata, MediaSource } from '@/types' import { parseTime, secondsToDurationString } from '@/util' import SafeHTML from '@/components/generic/SafeHTML' import AEditDialog from '@/components/generic/AEditDialog.vue' import FormTable from '@/components/generic/FormTable.vue' import FormGroup from '@/components/generic/FormGroup.vue' -import AFileImportLog from '@/components/playlist/AFileImportLog.vue' +import AFileImportLog from '@/components/media/AFileImportLog.vue' import AStatus from '@/components/generic/AStatus.vue' -const entry = defineModel<Partial<PlaylistEntry>>('entry', { required: true }) -withDefaults( +const props = withDefaults( defineProps<{ - dragHandle?: boolean - canEdit?: boolean + mediaSource: MediaSource as?: string }>(), { as: 'div', - canEdit: true, - dragHandle: false, }, ) -const emit = defineEmits<{ delete: [] }>() const fileStore = useFilesStore() +const mediaSourceStore = useMediaSourceStore() + const { t } = useI18n() const editDialog = ref() const fileImportLogDialog = ref() +const isDeleted = ref(false) -const { obj: file } = useObjectFromStore(() => entry.value?.fileId ?? null, fileStore) +const { obj: file } = useObjectFromStore(() => props.mediaSource.fileId ?? null, fileStore) const title = useCopy(() => file.value?.metadata?.title) const artist = useCopy(() => file.value?.metadata?.artist) const album = useCopy(() => file.value?.metadata?.album) -const duration = useCopy(() => entry.value?.duration) -const uri = computed(() => (entry.value.uri ? new URL(entry.value.uri as string) : null)) +const duration = useCopy(() => props.mediaSource.duration) +const uri = computed(() => + props.mediaSource.uri ? new URL(props.mediaSource.uri as string) : null, +) const type = computed(() => (uri.value ? uri.value.protocol.replace(':', '') : 'file')) -const input = useInput(() => entry.value.uri ?? '') +const input = useInput(() => props.mediaSource.uri ?? '') const artistInfo = computed(() => { const metadata = file.value?.metadata const artist = metadata?.artist ?? '' @@ -203,14 +205,14 @@ const artistInfo = computed(() => { function setDuration(event: Event) { const value = (event.target as HTMLInputElement).value - // A duration of zero should be treated as undefined so that the + // A duration of zero should be treated as null so that the // default playout behaviour for non-fixed-lengths entries can take over. - duration.value = parseTime(value) || undefined + duration.value = parseTime(value) || null } async function save() { - if (duration.value !== entry.value.duration) { - entry.value = { ...entry.value, duration: duration.value } + if (duration.value !== props.mediaSource.duration) { + await mediaSourceStore.partialUpdate(props.mediaSource.id, { duration: duration.value }) } if (file.value) { @@ -226,4 +228,15 @@ async function save() { editDialog.value.close() } + +async function remove() { + isDeleted.value = true + editDialog.value.close() + try { + await mediaSourceStore.remove(props.mediaSource.id) + } catch (e) { + isDeleted.value = false + throw e + } +} </script> diff --git a/src/components/media/AMediaSourceJob.vue b/src/components/media/AMediaSourceJob.vue new file mode 100644 index 00000000..ba801f6a --- /dev/null +++ b/src/components/media/AMediaSourceJob.vue @@ -0,0 +1,170 @@ +<template> + <component + :is="as" + ref="jobInfoEl" + class="tw-rounded tw-block tw-m-0 tw-p-3 tw-border tw-bg-gray-50 dark:tw-bg-neutral-800 dark:tw-border-neutral-700" + > + <div class="tw-flex tw-items-center tw-gap-3"> + <span class="tw-truncate tw-leading-none/75 tw-flex-1"> + <span class="tw-block">{{ job.name }}</span> + <span + class="tw-block tw-text-sm tw-truncate tw-max-w-[95%] tw-text-gray-500 dark:tw-text-neutral-500" + > + <span>{{ label }}</span> + <Dots v-if="!hasStopped" /> + </span> + </span> + + <div class="tw-flex tw-gap-2 tw-mr-1 tw-flex-none"> + <template v-if="canBeCancelled"> + <ACircularProgress + :progress=" + job.status.key === 'processing' + ? job.status.progress ?? 'indeterminate' + : 'indeterminate' + " + class="tw-size-8 tw-text-emerald-600 tw-grid-area-cover" + :aria-label="t('file.uploadProgress')" + /> + + <button + type="button" + class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none" + :title="t('cancel')" + @click="cancel(job.id)" + > + <icon-system-uicons-close class="tw-w-6 tw-h-6" /> + <span class="tw-sr-only">{{ t('cancel') }}</span> + </button> + </template> + + <button + v-if="canBeRestarted" + type="button" + class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none" + :title="t('file.retryUpload')" + @click="retry(job.id)" + > + <icon-pajamas-retry class="tw-size-4" /> + <span class="tw-sr-only">{{ t('file.retryUpload') }}</span> + </button> + + <template v-if="hasStopped"> + <button + v-if="fileImportLogDialog" + type="button" + class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none" + :title="t('file.importLog.showImportLog')" + @click="fileImportLogDialog.open()" + > + <icon-system-uicons-terminal class="tw-w-5 tw-h-5" /> + <span class="tw-sr-only">{{ t('file.importLog.showImportLog') }}</span> + </button> + <button + type="button" + class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none" + :title="t('dismiss')" + @click="dismiss(job.id)" + > + <icon-system-uicons-trash class="tw-w-5 tw-h-5" /> + <span class="tw-sr-only">{{ t('dismiss') }}</span> + </button> + </template> + </div> + </div> + + <div class="empty:tw-hidden"> + <AErrorList v-if="errors.length > 0" :errors="errors" class="tw-text-sm" /> + <SafeHTML + v-else-if="description" + as="p" + :html="description" + class="invalid-feedback tw-text-sm" + sanitize-preset="inline-noninteractive" + /> + <p v-if="mayHaveBeenRejectedBecauseOfFirefoxBug" class="invalid-feedback tw-text-sm"> + {{ t('media.editor.uploadedFiles.geckoBug') }} + </p> + </div> + + <AFileImportLog v-if="job.tankFile" ref="fileImportLogDialog" :file-id="job.tankFile.id" /> + </component> +</template> + +<script setup lang="ts"> +import { match } from 'ts-pattern' +import { useI18n } from '@/i18n' +import ACircularProgress from '@/components/generic/ACircularProgress.vue' +import Dots from '@/components/generic/Dots.vue' +import { + MediaSourceJob, + useBasicMediaSourceController, + useJobHasState, +} from '@/stores/media-manager' +import { computed, ref } from 'vue' +import AFileImportLog from '@/components/media/AFileImportLog.vue' +import SafeHTML from '@/components/generic/SafeHTML' +import { useErrorList } from '@rokoli/bnb/drf' +import AErrorList from '@/components/generic/AErrorList.vue' + +const props = withDefaults( + defineProps<{ + job: MediaSourceJob + as?: string + }>(), + { + as: 'div', + }, +) + +const { t, te } = useI18n() +const { dismiss, cancel, retry } = useBasicMediaSourceController() + +const errors = useErrorList(() => props.job.error) + +const fileImportLogDialog = ref() +const canBeCancelled = useJobHasState(() => props.job, ['queued', 'processing']) +const hasStopped = useJobHasState( + () => props.job, + ['cancelled', 'failed', 'failedPermanently', 'rejectedLocally', 'rejectedRemotely'], +) +const canBeRestarted = useJobHasState(() => props.job, ['failed', 'cancelled']) + +const label = computed(() => { + const { status } = props.job + const state = (name: string) => () => t(`mediaSourceJob.state.label.${name}`) + return match(status) + .with({ key: 'queued' }, state('queued')) + .with({ key: 'rejectedRemotely' }, state('discarded')) + .with({ key: 'rejectedLocally', reason: 'notAnAudioFile' }, state('notAnAudioFile')) + .with({ key: 'rejectedLocally' }, state('discarded')) + .with({ key: 'failedPermanently' }, state('failed')) + .with({ key: 'failed' }, state('failed')) + .with({ key: 'processing', phase: 'media:save' }, state('savingMedia')) + .with({ key: 'processing', phase: 'file:fetching' }, state('importingFile')) + .with({ key: 'processing', phase: 'file:normalizing' }, state('normalizingFile')) + .with({ key: 'processing' }, state('processing')) + .with({ key: 'cancelled' }, state('cancelled')) + .exhaustive() +}) +const description = computed(() => { + const key = `mediaSourceJob.state.description.${props.job.status.key}` + return te(key) ? t(key) : null +}) + +// Firefox has an annoying bug that classifies some files as video/ogg despite them being audio. +// We want users to know that they may be able to upload the file if they don’t use Firefox for it. +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1240259 +// https://gitlab.servus.at/aura/aura/-/issues/389 +const mayHaveBeenRejectedBecauseOfFirefoxBug = computed(() => { + const { name, status } = props.job + // Job must be rejected because the provided file is seemingly not an audio file. + if (status.key !== 'rejectedLocally' || status.reason !== 'notAnAudioFile') return false + // File must be an ogg file + if (!name.endsWith('.ogg')) return false + // must be firefox + if (!('navigator' in globalThis && globalThis.navigator.userAgent.includes('Gecko/'))) + return false + return true +}) +</script> diff --git a/src/components/media/AMediaSourceJobQueue.vue b/src/components/media/AMediaSourceJobQueue.vue new file mode 100644 index 00000000..8b08a92c --- /dev/null +++ b/src/components/media/AMediaSourceJobQueue.vue @@ -0,0 +1,23 @@ +<template> + <AFragment v-if="queue.length > 0" :wrap-in="label ? FormGroup : undefined" :label="label"> + <ol class="tw-flex tw-flex-col tw-gap-2 tw-p-0 tw-m-0"> + <li v-for="job in queue" :key="job.id" class="tw-flex-1"> + <AMediaSourceJob class="tw-w-full" :job="job" /> + </li> + </ol> + </AFragment> +</template> + +<script lang="ts" setup> +import { useMediaSourceQueue } from '@/stores/media-manager' +import AMediaSourceJob from '@/components/media/AMediaSourceJob.vue' +import FormGroup from '@/components/generic/FormGroup.vue' +import AFragment from '@/components/generic/AFragment.vue' + +const props = defineProps<{ + contextKey?: string | null | undefined + label?: string | null +}>() + +const queue = useMediaSourceQueue(() => props.contextKey) +</script> diff --git a/src/components/media/AMediaSources.vue b/src/components/media/AMediaSources.vue new file mode 100644 index 00000000..8e662204 --- /dev/null +++ b/src/components/media/AMediaSources.vue @@ -0,0 +1,72 @@ +<template> + <ol ref="mediaSourcesEl" class="tw-m-0 tw-p-0 tw-gap-1 media-sources"> + <AMediaSourceEditor + v-for="source in sources" + :key="source.id" + :media-source="source" + as="li" + :drag-handle="sources.length === 1 ? false : canSort" + :can-edit="canEdit" + /> + </ol> +</template> + +<script lang="ts" setup> +import { useSortable } from '@vueuse/integrations/useSortable' +import { computed, ref } from 'vue' + +import { MediaSource } from '@/types' +import AMediaSourceEditor from '@/components/media/AMediaSourceEditor.vue' +import { useMediaSourceStore } from '@/stores' +import { useCopy } from '@/form' +import { useCached, watchDebounced } from '@vueuse/core' +import { isEqual } from 'lodash' + +const props = withDefaults( + defineProps<{ + sources: MediaSource[] + canSort?: boolean + canEdit?: boolean + }>(), + { + canSort: true, + canEdit: true, + }, +) + +const mediaSourceStore = useMediaSourceStore() +const orderedSources = useCopy(() => props.sources) +const reorderData = useCached( + computed(() => orderedSources.value.map((item, index) => ({ id: item.id, order: index }))), + isEqual, +) + +watchDebounced(reorderData, (data) => mediaSourceStore.reorder(data), { debounce: 200 }) + +const mediaSourcesEl = ref<HTMLOListElement>() +useSortable(mediaSourcesEl, orderedSources, { + animation: 200, + ghostClass: 'media-source-drag-ghost', + handle: '[data-drag-handle]', + disabled: !props.canSort, +}) +</script> + +<style lang="postcss" scoped> +.media-sources { + display: grid; + grid-template-columns: min-content minmax(100px, 1fr) max-content; + + & > * { + display: grid !important; + grid-template-columns: subgrid; + grid-column: 1 / span 4; + } +} +</style> + +<style lang="postcss"> +.media-source-drag-ghost { + @apply tw-rounded tw-opacity-40 tw-border-2 tw-border-solid tw-border-aura-primary; +} +</style> diff --git a/src/components/playlist/AStreamURLDialog.vue b/src/components/media/AStreamURLDialog.vue similarity index 88% rename from src/components/playlist/AStreamURLDialog.vue rename to src/components/media/AStreamURLDialog.vue index cd6477e3..b4161318 100644 --- a/src/components/playlist/AStreamURLDialog.vue +++ b/src/components/media/AStreamURLDialog.vue @@ -1,8 +1,8 @@ <template> <AEditDialog ref="dialog" - :title="t('playlist.editor.addStreamDialog.title')" - :save-label="t('playlist.editor.addStreamDialog.title')" + :title="t('media.editor.addStreamDialog.title')" + :save-label="t('media.editor.addStreamDialog.title')" :can-save="canSave" :save="() => emit('save', url)" class="md:tw-w-[600px]" diff --git a/src/components/playlist/APlaylistEditor.vue b/src/components/playlist/APlaylistEditor.vue deleted file mode 100644 index 31bf2801..00000000 --- a/src/components/playlist/APlaylistEditor.vue +++ /dev/null @@ -1,384 +0,0 @@ -<template> - <div class="tw-relative" v-bind="attrs"> - <SaveIndicator - v-if="isUpdatingPlaylist" - state="pending" - class="tw-absolute tw-right-0 -tw-top-7 tw-z-10 -tw-translate-y-full" - role="alert" - aria-live="polite" - :aria-label="t('playlist.editor.isSaving')" - /> - - <APlaylistDurationCheck - v-if="playlist && playlist.entries.length > 0 && requiredDurationSeconds > 0" - :playlist="playlist" - :required-duration-seconds="requiredDurationSeconds" - class="tw-mb-6" - /> - - <APlaylistEntries - v-if="entries.length > 0" - v-model:entries="entries" - class="tw-mb-6" - :can-sort="!disabled" - :can-edit="!disabled" - /> - - <p v-if="disabled && entries.length === 0" class="tw-m-0"> - {{ t('playlist.editor.noEntries') }} - </p> - - <div - v-if="nonAddedEntries.length > 0" - class="tw-border-2 tw-border-rose-200 tw-border-solid tw-rounded tw-p-3 tw-mb-6" - > - <div v-if="entryUpdateErrorList.length > 0" class="tw-mb-2"> - {{ t('playlist.editor.entriesAdd.error') }} - <AErrorList :errors="entryUpdateErrorList" /> - </div> - - <APlaylistEntries - :entries="nonAddedEntries" - :can-sort="false" - :can-edit="false" - class="tw-mb-3" - /> - - <div class="tw-flex tw-items-center tw-gap-3"> - <button - type="button" - class="btn btn-default btn-sm" - :disabled="isUpdatingPlaylist" - @click="retryAddEntriesToPlaylist" - > - <Loading v-if="isRetrying" class="tw-h-1 tw-mr-1" /> - {{ t('playlist.editor.entriesAdd.retry') }} - </button> - <button - type="button" - class="btn btn-danger btn-sm" - :disabled="isUpdatingPlaylist" - @click="nonAddedEntries = []" - > - {{ t('playlist.editor.entriesAdd.discard') }} - </button> - </div> - </div> - - <section - v-if="uploadedFiles.size > 0" - class="tw-mb-6" - :aria-label="t('playlist.editor.upload.pending')" - > - <ul class="tw-flex tw-flex-wrap tw-gap-2 tw-p-0 tw-m-0"> - <template v-for="[id, entry] in uploadedFiles" :key="id"> - <AUploadProgress - as="li" - :name="entry.name" - :file-id="id" - :retry="entry.retry" - :error="entry.error" - class="tw-max-w-xs" - /> - </template> - </ul> - </section> - - <fieldset - v-if="!disabled" - ref="dropzoneEl" - class="tw-rounded tw-border-2 tw-flex tw-mb-3 tw-p-6 dark:tw-border-neutral-700" - :class="{ - 'tw-border-teal-600': isOverDropZone && isAllowedToAddFiles, - 'tw-border-gray-200': !isOverDropZone, - 'tw-border-dashed': isAllowedToAddFiles, - }" - > - <div class="tw-place-self-center tw-mx-auto tw-flex tw-flex-col tw-gap-2 tw-items-center"> - <template v-if="isAllowedToAddFiles"> - <icon-system-uicons-file-upload class="tw-text-xl" /> - <p class="tw-mb-0">{{ t('playlist.editor.control.dropFiles') }}</p> - <p class="tw-text-gray-400 tw-text-sm tw-mb-1.5 tw-leading-none"> - {{ t('playlist.editor.control._or') }} - </p> - </template> - <div class="tw-flex tw-flex-wrap tw-justify-center tw-items-center tw-gap-3"> - <button - v-if="isAllowedToAddFiles" - type="button" - class="btn btn-default" - data-testid="playlist-editor:open-file-dialog" - @click="openFileDialog()" - > - <icon-iconamoon-file-audio-thin class="tw-flex-none" /> - {{ t('playlist.editor.control.selectFiles') }} - </button> - <APermissionGuard show-permissions="program.add__import"> - <button type="button" class="btn btn-default" @click="importFileFromURL()"> - <icon-formkit-url class="tw-flex-none" /> - {{ t('playlist.editor.control.importFile') }} - </button> - <GetFileImportUrl v-slot="{ resolve }"> - <AFileUrlDialog @save="resolve($event)" @close="resolve(null)" /> - </GetFileImportUrl> - </APermissionGuard> - </div> - <div class="tw-flex tw-flex-wrap tw-justify-center tw-items-center tw-gap-3 tw-mt-1"> - <APermissionGuard show-permissions="program.add__stream"> - <button type="button" class="btn btn-default" @click="addStreamToPlaylist()"> - <icon-solar-play-stream-bold class="tw-flex-none" /> - {{ t('playlist.editor.control.addStream') }} - </button> - <GetStreamUrl v-slot="{ resolve }"> - <AStreamURLDialog @save="resolve($event)" @close="resolve(null)" /> - </GetStreamUrl> - </APermissionGuard> - <APermissionGuard show-permissions="program.add__line"> - <button type="button" class="btn btn-default" @click="addInputToPlaylist"> - <icon-game-icons-jack-plug class="tw-flex-none" /> - {{ t('playlist.editor.control.addInput') }} - </button> - <GetInputUrl v-slot="{ resolve }"> - <AInputUrlDialog @save="resolve($event)" @close="resolve(null)" /> - </GetInputUrl> - </APermissionGuard> - <APermissionGuard show-permissions="program.add__m3ufile"> - <button type="button" class="btn btn-default" @click="addM3utoPlaylist"> - <icon-ph-playlist-light class="tw-flex-none" /> - {{ t('playlist.editor.control.addM3u') }} - </button> - <GetM3uUrl v-slot="{ resolve }"> - <AM3uUrlDialog @save="resolve($event)" @close="resolve(null)" /> - </GetM3uUrl> - </APermissionGuard> - </div> - </div> - </fieldset> - </div> -</template> - -<script lang="ts" setup> -import { APIResponseError, useErrorList } from '@rokoli/bnb/drf' -import { createTemplatePromise, useDropZone, useFileDialog } from '@vueuse/core' -import { partition } from 'lodash' -import { ref, useAttrs, watch } from 'vue' - -import { useCopy } from '@/form' -import { useI18n } from '@/i18n' -import { useFilesStore, usePlaylistStore } from '@/stores' -import { File as TankFile, Playlist, PlaylistEntry, Show } from '@/types' -import { ensureError, getFilenameFromURL, sanitizeHTML, useAsyncFunction } from '@/util' - -import AStreamURLDialog from '@/components/playlist/AStreamURLDialog.vue' -import AFileUrlDialog from '@/components/playlist/AFileUrlDialog.vue' -import AInputUrlDialog from '@/components/playlist/AInputUrlDialog.vue' -import AM3uUrlDialog from '@/components/playlist/AM3uUrlDialog.vue' -import Loading from '@/components/generic/Loading.vue' -import AUploadProgress from '@/components/playlist/AUploadProgress.vue' -import SaveIndicator from '@/components/generic/SaveIndicator.vue' -import APlaylistDurationCheck from '@/components/playlist/APlaylistDurationCheck.vue' -import APlaylistEntries from '@/components/playlist/APlaylistEntries.vue' -import APermissionGuard from '@/components/generic/APermissionGuard.vue' -import { useHasUserPermission } from '@/stores/auth' -import { useToast } from 'vue-toast-notification' -import AErrorList from '@/components/generic/AErrorList.vue' - -const props = withDefaults( - defineProps<{ - show: Show - requiredDurationSeconds?: number - playlist: Playlist | null - disabled?: boolean - }>(), - { - requiredDurationSeconds: -1, - }, -) -const emit = defineEmits<{ - create: [Playlist] -}>() - -const attrs = useAttrs() -const { t } = useI18n() -const fileStore = useFilesStore() -const playlistStore = usePlaylistStore() -const toast = useToast() - -const GetStreamUrl = createTemplatePromise<string | null>() -const GetFileImportUrl = createTemplatePromise<string | null>() -const GetInputUrl = createTemplatePromise<string | null>() -const GetM3uUrl = createTemplatePromise<string | null>() - -const entries = useCopy(() => props.playlist?.entries ?? [], { - save: () => updatePlaylistEntries(), -}) -const entryUpdateError = ref<Error>() -const entryUpdateErrorList = useErrorList(entryUpdateError) -const nonAddedEntries = ref<Partial<PlaylistEntry>[]>([]) -const isRetrying = ref(false) - -const isAllowedToAddFiles = useHasUserPermission(['program.add__file']) - -const uploadedFiles = ref( - new Map< - TankFile['id'], - { tankFile: TankFile; retry?: () => unknown; name: string; error?: Error } - >(), -) -const dropzoneEl = ref<HTMLDivElement>() -const { open: openFileDialog, files } = useFileDialog({ accept: 'audio/*', multiple: true }) -watch(files, (newFiles) => uploadFiles(Array.from(newFiles ?? []))) -const { isOverDropZone } = useDropZone(dropzoneEl, { - onDrop: (files) => { - if (isAllowedToAddFiles.value) { - uploadFiles(files ?? []) - } - }, -}) - -async function retryUpload(tankFile: TankFile, file: File) { - try { - await fileStore.remove(tankFile.id) - } catch (err) { - const record = uploadedFiles.value.get(tankFile.id) - if (record) record.error = ensureError(err) - return - } - uploadedFiles.value.delete(tankFile.id) - await uploadFiles([file]) -} - -function warnOnDiscardedFiles(discardedFiles: File[]) { - if (discardedFiles.length === 0) return - - const hasOgg = discardedFiles.some((file) => file.name.endsWith('.ogg')) - const isGecko = 'navigator' in globalThis && globalThis.navigator.userAgent.includes('Gecko/') - const suffix = hasOgg && isGecko ? t('playlist.editor.uploadFiles.geckoBug') : '' - const discardedFileList = `<ul>${discardedFiles.map((file) => `<li>${sanitizeHTML(file.name)}</li>`).join('\n')}</ul>` - const message = - t('playlist.editor.uploadFiles.discarded', { discardedFileList: discardedFileList }) + suffix - toast.warning(message, { dismissible: true, duration: 0 }) -} - -async function uploadFiles(files: File[]) { - const [audioFiles, discardedFiles] = partition(files, (f) => f.type.startsWith('audio/')) - warnOnDiscardedFiles(discardedFiles) - const fileAddResults = await Promise.allSettled( - audioFiles.map((file) => { - let _tankFile: TankFile | null = null - return fileStore - .uploadFile(file, props.show, { - onDone: (tankFile) => uploadedFiles.value.delete(tankFile.id), - onCreate: (tankFile) => { - _tankFile = tankFile - uploadedFiles.value.set(tankFile.id, { - tankFile, - retry: () => retryUpload(tankFile, file), - name: file.name, - }) - }, - }) - .catch((err) => { - const _err = ensureError(err) - if (_tankFile) { - const record = uploadedFiles.value.get(_tankFile.id) - if (record) record.error = _err - } - throw _err - }) - }), - ) - const addedFiles = fileAddResults - .filter((r) => r.status === 'fulfilled') - .map((r) => (r as PromiseFulfilledResult<TankFile>).value) - if (addedFiles.length > 0) await addFileToPlaylist(...addedFiles) - - const failedUploads = fileAddResults - .filter((r) => r.status === 'rejected') - .map((r) => ensureError((r as PromiseRejectedResult).reason)) - if (failedUploads.length > 0) { - console.error('Some files failed to upload', failedUploads) - } -} - -async function importFileFromURL() { - const fileUrl = await GetFileImportUrl.start() - if (!fileUrl) return - const tankFile = await fileStore.importFileURL(fileUrl, props.show, { - onDone: (tankFile) => uploadedFiles.value.delete(tankFile.id), - onCreate: (tankFile) => { - uploadedFiles.value.set(tankFile.id, { - tankFile, - name: getFilenameFromURL(fileUrl), - }) - }, - }) - await addFileToPlaylist(tankFile) -} - -async function createPlaylist() { - const playlist = await playlistStore.create({ showId: props.show.id }) - emit('create', playlist) - return playlist -} - -async function addFileToPlaylist(...files: TankFile[]) { - await updatePlaylistEntries( - ...files.map((file) => ({ fileId: file.id, duration: file.duration })), - ) -} - -async function addStreamToPlaylist() { - const streamURL = await GetStreamUrl.start() - if (!streamURL) return - await updatePlaylistEntries({ uri: streamURL }) -} - -async function addInputToPlaylist() { - const inputUrl = await GetInputUrl.start() - if (!inputUrl) return - await updatePlaylistEntries({ uri: inputUrl }) -} - -async function addM3utoPlaylist() { - const m3uUrl = await GetM3uUrl.start() - if (!m3uUrl) return - await updatePlaylistEntries({ uri: m3uUrl }) -} - -type NewPlaylistEntry = - | Pick<PlaylistEntry, 'fileId' | 'duration'> - | Pick<PlaylistEntry, 'uri' | 'duration'> - -const { fn: updatePlaylistEntries, isProcessing: isUpdatingPlaylist } = useAsyncFunction( - async function (...newEntries: Partial<NewPlaylistEntry>[]) { - entryUpdateError.value = undefined - try { - const playlist = props.playlist ? props.playlist : await createPlaylist() - await playlistStore.partialUpdate(playlist.id, { - entries: [...entries.value, ...newEntries], - }) - } catch (e) { - if (e instanceof APIResponseError) { - entryUpdateError.value = e - } - nonAddedEntries.value = [...nonAddedEntries.value, ...newEntries] - throw e - } - }, -) - -async function retryAddEntriesToPlaylist() { - if (nonAddedEntries.value.length === 0) return - isRetrying.value = true - const retryEntries = nonAddedEntries.value - try { - await updatePlaylistEntries(...retryEntries) - nonAddedEntries.value = [] - } catch (e) { - nonAddedEntries.value = retryEntries - } finally { - isRetrying.value = false - } -} -</script> diff --git a/src/components/playlist/APlaylistEntries.vue b/src/components/playlist/APlaylistEntries.vue deleted file mode 100644 index 5aa44df3..00000000 --- a/src/components/playlist/APlaylistEntries.vue +++ /dev/null @@ -1,64 +0,0 @@ -<template> - <component - :is="entries.length === 1 ? 'div' : 'ol'" - ref="playlistEntriesEl" - class="tw-m-0 tw-p-0 tw-gap-1 playlist-entries" - > - <APlaylistEntryEditor - v-for="(entry, index) in entries" - :key="entry.fileId ?? entry.uri" - v-model:entry="entries[index]" - :as="entries.length === 1 ? 'div' : 'li'" - :drag-handle="entries.length === 1 ? false : canSort" - :can-edit="canEdit" - @delete="entries.splice(index, 1)" - /> - </component> -</template> - -<script lang="ts" setup> -import { useSortable } from '@vueuse/integrations/useSortable' -import { ref } from 'vue' - -import { PlaylistEntry } from '@/types' -import APlaylistEntryEditor from '@/components/playlist/APlaylistEntryEditor.vue' - -const entries = defineModel<Partial<PlaylistEntry>[]>('entries', { required: true }) -const props = withDefaults( - defineProps<{ - canSort?: boolean - canEdit?: boolean - }>(), - { - canSort: true, - canEdit: true, - }, -) - -const playlistEntriesEl = ref<HTMLOListElement>() -useSortable(playlistEntriesEl, entries, { - animation: 200, - ghostClass: 'playlist-entry-drag-ghost', - handle: '[data-drag-handle]', - disabled: !props.canSort, -}) -</script> - -<style lang="postcss" scoped> -.playlist-entries { - display: grid; - grid-template-columns: min-content minmax(100px, 1fr) max-content; - - & > * { - display: grid !important; - grid-template-columns: subgrid; - grid-column: 1 / span 4; - } -} -</style> - -<style lang="postcss"> -.playlist-entry-drag-ghost { - @apply tw-rounded tw-opacity-40 tw-border-2 tw-border-solid tw-border-aura-primary; -} -</style> diff --git a/src/components/playlist/AUploadProgress.vue b/src/components/playlist/AUploadProgress.vue deleted file mode 100644 index 8252e1a7..00000000 --- a/src/components/playlist/AUploadProgress.vue +++ /dev/null @@ -1,63 +0,0 @@ -<template> - <component - :is="as" - class="tw-bg-emerald-100 tw-rounded-full tw-m-0 tw-pl-4 tw-pr-1 tw-py-1 tw-w-fit tw-flex tw-items-center tw-gap-3" - :class="error ? 'tw-bg-rose-100' : 'tw-bg-emerald-100'" - > - <span class="tw-truncate tw-leading-none tw-text-black/75"> - <span class="tw-block tw-text-sm tw-font-medium">{{ name }}</span> - <span class="tw-block tw-text-xs tw-truncate tw-max-w-[95%]" :title="error?.message"> - <template v-if="!error"> - {{ t(`playlist.editor.upload.step.${importProgress?.step ?? 'unknown'}`) }}<Dots /> - </template> - <template v-else>{{ t('file.uploadError') }}: {{ error.message }}</template> - </span> - </span> - - <ACircularProgress - v-if="!error" - :progress="progress" - class="tw-size-8 tw-flex-none tw-text-emerald-600" - :aria-label="t('file.uploadProgress')" - /> - <button - v-else-if="error && retry" - type="button" - class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none" - :title="t('file.retryUpload')" - @click="retry" - > - <icon-pajamas-retry class="tw-size-4" /> - <span class="tw-sr-only">{{ t('file.retryUpload') }}</span> - </button> - </component> -</template> - -<script setup lang="ts"> -import { computed } from 'vue' - -import { useI18n } from '@/i18n' -import { useFileUploadProgress } from '@/stores/files' -import { File as TankFile } from '@/types' -import ACircularProgress from '@/components/generic/ACircularProgress.vue' -import Dots from '@/components/generic/Dots.vue' - -const props = withDefaults( - defineProps<{ - as?: string - fileId: TankFile['id'] - name: string - retry?: (() => unknown) | undefined - error?: Error | undefined - }>(), - { - as: 'p', - error: undefined, - retry: undefined, - }, -) - -const { t } = useI18n() -const importProgress = useFileUploadProgress(() => props.fileId) -const progress = computed(() => importProgress.value?.progress || 'indeterminate') -</script> diff --git a/src/components/schedule/AScheduleCreateDialog.vue b/src/components/schedule/AScheduleCreateDialog.vue index 6ca5f79f..4a156ef7 100644 --- a/src/components/schedule/AScheduleCreateDialog.vue +++ b/src/components/schedule/AScheduleCreateDialog.vue @@ -182,7 +182,7 @@ const { fn: create, isProcessing } = useAsyncFunction(async () => { rruleId: rruleId.value as RRule['id'], byWeekday: getWeekdayFromApiDate(firstDate.value), showId: showId.value as Show['id'], - defaultPlaylistId: null, + defaultMediaId: null, } if (lastDate.value) { diff --git a/src/components/schedule/AScheduleEditDialog.vue b/src/components/schedule/AScheduleEditDialog.vue index 448309d7..5376b3d3 100644 --- a/src/components/schedule/AScheduleEditDialog.vue +++ b/src/components/schedule/AScheduleEditDialog.vue @@ -274,7 +274,7 @@ async function createRepetitionSchedule() { const _schedule = schedule.value if (!_schedule) return - const { firstDate, rruleId, lastDate, defaultPlaylistId, byWeekday } = _schedule + const { firstDate, rruleId, lastDate, defaultMediaId, byWeekday } = _schedule let { startTime, endTime } = _schedule const repetitionDefaults = repetitionRules.value.find((r) => r.id === repetitionRuleId.value)?.defaults ?? {} @@ -298,7 +298,7 @@ async function createRepetitionSchedule() { endTime, rruleId, lastDate, - defaultPlaylistId, + defaultMediaId, byWeekday, } diff --git a/src/components/shows/TimeSlotRow.vue b/src/components/shows/TimeSlotRow.vue index 0970fa65..11b261a1 100644 --- a/src/components/shows/TimeSlotRow.vue +++ b/src/components/shows/TimeSlotRow.vue @@ -52,10 +52,10 @@ <AStatus class="tw-text-xs" rounded - :is-warning="playlistState.state !== 'ok'" - :is-success="playlistState.state === 'ok'" + :is-warning="mediaState.state !== 'ok'" + :is-success="mediaState.state === 'ok'" > - {{ t(`playlist.state.${playlistState.state}.title`) }} + {{ t(`media.state.${mediaState.state}.title`) }} </AStatus> </td> <td class="tw-relative tw-p-0" :class="{ 'tw-w-6': isOnAir || isNextUp }"> @@ -82,8 +82,8 @@ import { useI18n } from '@/i18n' import { TimeSlot } from '@/types' import { usePretty } from '@/mixins/prettyDate' import { calculateDurationSeconds, secondsToDurationString } from '@/util' -import { useEpisodeStore, usePlaylistStore, useTimeSlotStore } from '@/stores' -import { usePlaylistState } from '@/stores/playlists' +import { useEpisodeStore, useMediaStore, useTimeSlotStore } from '@/stores' +import { useMediaState } from '@/stores/media' import AStatus from '@/components/generic/AStatus.vue' import APermissionGuard from '@/components/generic/APermissionGuard.vue' import AEpisodeAssignmentManager from '@/components/episode/AEpisodeAssignmentManager.vue' @@ -105,7 +105,7 @@ const { t } = useI18n() const episodePopoverId = useId() const { prettyDateTime } = usePretty() const timeslotStore = useTimeSlotStore() -const playlistStore = usePlaylistStore() +const mediaStore = useMediaStore() const episodeStore = useEpisodeStore() const episodeId = useAPIObjectFieldCopy(timeslotStore, () => props.timeslot, 'episodeId', { @@ -114,8 +114,8 @@ const episodeId = useAPIObjectFieldCopy(timeslotStore, () => props.timeslot, 'ep const { obj: episode } = useObjectFromStore(() => props.timeslot.episodeId, episodeStore) const duration = computed(() => calculateDurationSeconds(props.timeslot.start, props.timeslot.end)) -const { obj: playlist } = useObjectFromStore(() => episode.value?.mediaId ?? null, playlistStore) -const playlistState = usePlaylistState(playlist, duration) +const { obj: media } = useObjectFromStore(() => episode.value?.mediaId ?? null, mediaStore) +const mediaState = useMediaState(media, duration) const rowClass = computed(() => { const now = new Date() const startDate = parseISO(props.timeslot.start) diff --git a/src/i18n/de.js b/src/i18n/de.js index 0ca008b3..7c8553df 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -119,6 +119,79 @@ export default { media: { singular: 'Medien', plural: 'Medien', + duration: 'Dauer', + editor: { + title: 'Medienquellen', + isSaving: 'Medienquellen werden gespeichert', + editMediaSource: 'Medienquelle bearbeiten', + deleteMediaSource: 'Medienquelle löschen', + noEntries: 'Es wurden keine Medienquellen zugewiesen.', + entriesAdd: { + retry: 'Erneut versuchen', + discard: 'Verwerfen', + error: 'Einige Einträge konnten nicht hinzugefügt werden.', + }, + upload: { + pending: 'Neue Quellen', + step: { + fetching: 'Importiere', + normalizing: 'Normalisiere', + unknown: 'Verarbeite', + }, + }, + uploadFiles: { + discarded: + '<p>Einige Dateien wurden aussortiert, weil sie nicht als Audiodateien erkannt wurden. Dazu gehören:</p>%{discardedFileList}', + geckoBug: + '<p>Firefox erkennt einige OGG/Vorbis Dateien fälschlicherweise als Videos. Du kannst das Problem mit einem anderen Browser umgehen.</p>', + }, + control: { + _or: '- oder -', + dropFiles: 'Ziehe Dateien auf diese Fläche', + selectFiles: 'Lokale Datei auswählen', + importFile: 'Datei von URL importieren', + addStream: 'Stream hinzufügen', + addInput: 'Eingang hinzufügen', + addM3u: 'M3U hinzufügen', + }, + importFileDialog: { + title: 'Datei von URL importieren', + saveLabel: 'Datei hinzufügen', + }, + addInputDialog: { + title: 'Eingang als Medienquelle hinzufügen', + saveLabel: 'Eingang hinzufügen', + }, + addStreamDialog: { + title: 'Stream als Medienquelle hinzufügen', + saveLabel: 'Stream hinzufügen', + }, + addM3uDialog: { + title: 'M3U als Medienquelle hinzufügen', + saveLabel: 'M3U hinzufügen', + }, + }, + state: { + ok: { title: 'Perfekt' }, + missing: { title: 'Fehlt' }, + indeterminate: { + title: 'Unbekannte Länge', + description: `Mindestens eine hinterlegte Quelle hat keine definierte Länge. + Es kann daher nicht geprüft werden, ob deine Quellen die Dauer der Ausstrahlung über- oder unterschreiten. + Die von dir hinterlegten Medienquellen, sollten eine Gesamtlänge von <strong>%{totalTime}</strong> erreichen. + Diese weichen derzeit um <strong>%{offset}</strong> davon ab.`, + }, + tooShort: { + title: 'Unterlänge', + description: `Die von dir hinterlegten Medienquellen unterschreiten die Dauer der Ausstrahlung um <strong>%{offset}</strong>. + <br>Bitte ergänze weitere Inhalte für deine Sendung.`, + }, + tooLong: { + title: 'Überlänge', + description: `Die von dir hinterlegten Medienquellen überschreiten die Dauer der Ausstrahlung um <strong>%{offset}</strong>. + <br>Bitte kürze deine Sendungsinhalt entsprechend.`, + }, + }, }, mediaSource: { @@ -246,16 +319,6 @@ export default { }, }, - showEpisode: { - title: 'Sendung am %{start}', - }, - - filePlaylistManager: { - title: 'Medien', - files: 'Dateien', - playlists: 'Playlists', - }, - credits: { title: 'Credits', intro: 'AURA wird unter der GNU Affero General Public License v3 entwickelt.', @@ -272,6 +335,7 @@ export default { loadingData: 'Lade %{items}', loading: 'Lädt..', cancel: 'Abbrechen', + dismiss: 'Verwerfen', yes: 'Ja', no: 'Nein', active: 'Aktiv', @@ -316,7 +380,6 @@ export default { _title: 'Sendung am %{start}', details: 'Beschreibung', }, - filesPlaylists: 'Medien', profiles: 'Profile', calendar: 'Kalender', settings: 'Einstellungen', @@ -371,6 +434,7 @@ export default { }, input: { + singular: 'Eingang', unknown: 'Unbekannter Eingang', }, @@ -401,83 +465,36 @@ export default { }, }, - playlist: { - duration: 'Dauer', - editor: { - title: 'Medienquellen', - isSaving: 'Medienquellen werden gespeichert', - editPlaylistEntry: 'Medienquelle bearbeiten', - deletePlaylistEntry: 'Medienquelle löschen', - noEntries: 'Es wurden keine Medienquellen zugewiesen.', - entriesAdd: { - retry: 'Erneut versuchen', - discard: 'Verwerfen', - error: 'Einige Einträge konnten nicht hinzugefügt werden.', - }, - upload: { - pending: 'Laufende Uploads', - step: { - fetching: 'Importiere', - normalizing: 'Normalisiere', - unknown: 'Verarbeite', - }, - }, - uploadFiles: { - discarded: - '<p>Einige Dateien wurden aussortiert, weil sie nicht als Audiodateien erkannt wurden. Dazu gehören:</p>%{discardedFileList}', - geckoBug: - '<p>Firefox erkennt einige OGG/Vorbis Dateien fälschlicherweise als Videos. Du kannst das Problem mit einem anderen Browser umgehen.</p>', - }, - control: { - _or: '- oder -', - dropFiles: 'Ziehe Dateien auf diese Fläche', - selectFiles: 'Lokale Datei auswählen', - importFile: 'Datei von URL importieren', - addStream: 'Stream hinzufügen', - addInput: 'Eingang hinzufügen', - addM3u: 'M3U hinzufügen', - }, - importFileDialog: { - title: 'Datei von URL importieren', - saveLabel: 'Datei hinzufügen', - }, - addInputDialog: { - title: 'Eingang als Medienquelle hinzufügen', - saveLabel: 'Eingang hinzufügen', - }, - addStreamDialog: { - title: 'Stream als Medienquelle hinzufügen', - saveLabel: 'Stream hinzufügen', - }, - addM3uDialog: { - title: 'M3U als Medienquelle hinzufügen', - saveLabel: 'M3U hinzufügen', - }, - }, + mediaSourceJob: { + showInfo: 'Details anzeigen', state: { - ok: { title: 'Perfekt' }, - missing: { title: 'Fehlt' }, - indeterminate: { - title: 'Unbekannte Länge', - description: `Mindestens eine hinterlegte Quelle hat keine definierte Länge. - Es kann daher nicht geprüft werden, ob deine Quellen die Dauer der Ausstrahlung über- oder unterschreiten. - Die von dir hinterlegten Medienquellen, sollten eine Gesamtlänge von <strong>%{totalTime}</strong> erreichen. - Diese weichen derzeit um <strong>%{offset}</strong> davon ab.`, + label: { + queued: 'Eingereiht', + discarded: 'Verworfen', + notAnAudioFile: 'Keine Audiodatei', + cancelled: 'Abgebrochen', + failed: 'Fehlgeschlagen', + processing: 'Verarbeite', + savingMedia: 'Speichere Metadaten', + importingFile: 'Importiere', + normalizingFile: 'Normalisiere', }, - tooShort: { - title: 'Unterlänge', - description: `Die von dir hinterlegten Medienquellen unterschreiten die Dauer der Ausstrahlung um <strong>%{offset}</strong>. - <br>Bitte ergänze weitere Inhalte für deine Sendung.`, - }, - tooLong: { - title: 'Überlänge', - description: `Die von dir hinterlegten Medienquellen überschreiten die Dauer der Ausstrahlung um <strong>%{offset}</strong>. - <br>Bitte kürze deine Sendungsinhalt entsprechend.`, + description: { + failed: + 'Bei der Verarbeitung der Medienquelle trat ein Fehler auf. Das kann ein temporäres Problem sein.', + cancelled: + 'Du hast den Importvorgang abgebrochen. Du kannst es erneut probieren, falls das ein Fehler war.', + failedPermanently: + 'Bei der Verarbeitung der Medienquelle trat ein schwerer Fehler auf. Bitte reiche einen Fehlerbericht ein.', + rejectedLocally: + 'Diese Medienquelle wurde als ungeeignet verworfen. Das kann z.B. passieren, wenn du eine Datei hochladen willst, die keine Audiodaten enthält.', + rejectedRemotely: + 'Diese Medienquelle wurde vom Server als ungeeignet abgelehnt. Bitte kontaktiere deine:n Administrator:in falls du denkst, dass das ein Fehler ist.', }, }, }, - playlistTable: { + mediaTable: { // Our translation framework automatically picks singular/plural items: '%{smart_count} Eintrag |||| %{smart_count} Einträge', assign: 'Zuweisen', @@ -575,7 +592,7 @@ export default { error: { server: { - 'multiple-null-duration-playlist-entries': + 'multiple-null-duration-media-sources': 'Es darf nicht mehr als eine Medienquelle ohne festgelegte Dauer geben. Bitte setze für die bestehenden Medienquellen eine Dauer.', 'no-fallback-show-defined': 'In den Einstellungen des Radios wurde keine Fallback-Show definiert. Diese muss für eine korrekte Darstellung des Kalenders gesetzt werden.', @@ -743,8 +760,6 @@ export default { singleEmission: 'Die Sendung ist eine einmalige Ausstrahlung.', coexistingTimeslot: 'Aufgrund einer Konfliktbehebung wurden jedoch weitere Sendungen angelegt.', - currentlySelectedPlaylist: 'Momentan hinterlegte Playlist', - start: 'Start', end: 'Ende', projectedDuration: '<b>Geplante Dauer:</b> %{duration}', @@ -791,8 +806,8 @@ export default { calendar: { today: 'Heute', - empty: 'Keine Playlist', - fallback: 'Fallback-Playlist', + empty: 'Keine Medien', + fallback: 'Fallback-Medien', repetition: 'Wiederholung', view: { day: 'Tagesansicht', diff --git a/src/i18n/en.js b/src/i18n/en.js index 86f2790f..d9f2389b 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -118,6 +118,80 @@ export default { media: { singular: 'media', plural: 'media', + + duration: 'Duration', + editor: { + title: 'Media sources', + isSaving: 'Media sources are being saved', + editMediaSource: 'Edit media sources', + deleteMediaSource: 'Delete media source', + noEntries: 'No media sources have been assigned.', + entriesAdd: { + retry: 'Try again', + discard: 'Discard', + error: 'Some of the media sources could not be added.', + }, + upload: { + pending: 'New sources', + step: { + fetching: 'Importing', + normalizing: 'Normalizing', + unknown: 'Processing', + }, + }, + uploadFiles: { + discarded: + '<p>Some files were discarded because they were not recognized as audio files. This includes:</p>%{discardedFileList}', + geckoBug: + '<p>Firefox is known to classify some OGG/Vorbis files as video files. You can use different browser to work around that.</p>', + }, + control: { + _or: '- or -', + dropFiles: 'Drag files on this area', + selectFiles: 'Select local file', + importFile: 'Import file from URL', + addStream: 'Add stream', + addInput: 'Add input', + addM3u: 'Add M3U', + }, + importFileDialog: { + title: 'Import file from URL', + saveLabel: 'Add file', + }, + addInputDialog: { + title: 'Add input as media source', + saveLabel: 'Add input', + }, + addStreamDialog: { + title: 'Add stream as media source', + saveLabel: 'Add stream', + }, + addM3uDialog: { + title: 'Add M3U as media source', + saveLabel: 'Add M3U', + }, + }, + state: { + ok: { title: 'Perfect' }, + missing: { title: 'Missing' }, + indeterminate: { + title: 'Unknown duration', + description: `At least one of the media sources does not have a fixed duration. + It is therefore not possible to check whether your sources exceed or fall short of the broadcast duration. + The media you have provided should have a total length of <strong>%{totalTime}</strong>. + The current sources are deviating from this by <strong>%{offset}</strong>.`, + }, + tooShort: { + title: 'Too short', + description: `The media sources you have provided fall short of the duration of the broadcast by <strong>%{offset}</strong>. + <br>Please add further content to your episode.`, + }, + tooLong: { + title: 'Too long', + description: `The media sources you have provided exceed the duration of the broadcast by <strong>%{offset}</strong>. + <br>Please shorten your episode content accordingly.`, + }, + }, }, mediaSource: { @@ -244,16 +318,6 @@ export default { }, }, - showEpisode: { - title: 'Episode from %{start}', - }, - - filePlaylistManager: { - title: 'Media', - files: 'Files', - playlists: 'Playlists', - }, - credits: { title: 'Credits', intro: 'AURA is developed under the GNU Affero General Public License v3.', @@ -270,6 +334,7 @@ export default { loadingData: 'Loading %{items}', loading: 'Loading..', cancel: 'Cancel', + dismiss: 'Dismiss', yes: 'Yes', no: 'No', active: 'Active', @@ -316,7 +381,6 @@ export default { _title: 'Episode from %{start}', details: 'Description', }, - filesPlaylists: 'Media', profiles: 'Profiles', calendar: 'Calendar', settings: 'Settings', @@ -368,6 +432,7 @@ export default { }, input: { + singular: 'Input', unknown: 'Unknown Input', }, @@ -398,83 +463,34 @@ export default { }, }, - playlist: { - duration: 'Duration', - editor: { - title: 'Media sources', - isSaving: 'Media sources are being saved', - editPlaylistEntry: 'Edit media sources', - deletePlaylistEntry: 'Delete media source', - noEntries: 'No media sources have been assigned.', - entriesAdd: { - retry: 'Try again', - discard: 'Discard', - error: 'Some of the media sources could not be added.', - }, - upload: { - pending: 'Pending uploads', - step: { - fetching: 'Importing', - normalizing: 'Normalizing', - unknown: 'Processing', - }, - }, - uploadFiles: { - discarded: - '<p>Some files were discarded because they were not recognized as audio files. This includes:</p>%{discardedFileList}', - geckoBug: - '<p>Firefox is known to classify some OGG/Vorbis files as video files. You can use different browser to work around that.</p>', - }, - control: { - _or: '- or -', - dropFiles: 'Drag files on this area', - selectFiles: 'Select local file', - importFile: 'Import file from URL', - addStream: 'Add stream', - addInput: 'Add input', - addM3u: 'Add M3U', - }, - importFileDialog: { - title: 'Import file from URL', - saveLabel: 'Add file', - }, - addInputDialog: { - title: 'Add input as media source', - saveLabel: 'Add input', - }, - addStreamDialog: { - title: 'Add stream as media source', - saveLabel: 'Add stream', - }, - addM3uDialog: { - title: 'Add M3U as media source', - saveLabel: 'Add M3U', - }, - }, + mediaSourceJob: { + showInfo: 'Show details', state: { - ok: { title: 'Perfect' }, - missing: { title: 'Missing' }, - indeterminate: { - title: 'Unknown duration', - description: `At least one of the media sources does not have a fixed duration. - It is therefore not possible to check whether your sources exceed or fall short of the broadcast duration. - The media you have provided should have a total length of <strong>%{totalTime}</strong>. - The current sources are deviating from this by <strong>%{offset}</strong>.`, + label: { + queued: 'Queued', + discarded: 'Discarded', + notAnAudioFile: 'Not an audio file', + cancelled: 'Cancelled', + failed: 'Failed', + processing: 'Processing', + savingMedia: 'Saving media metadata', + importingFile: 'Importing', + normalizingFile: 'Normalizing', }, - tooShort: { - title: 'Too short', - description: `The media sources you have provided fall short of the duration of the broadcast by <strong>%{offset}</strong>. - <br>Please add further content to your episode.`, - }, - tooLong: { - title: 'Too long', - description: `The media sources you have provided exceed the duration of the broadcast by <strong>%{offset}</strong>. - <br>Please shorten your episode content accordingly.`, + description: { + failed: 'An error occurred while processing this media source. This may be temporary.', + cancelled: 'You have aborted the import job. You may retry if this was an error.', + failedPermanently: + 'A grave error occurred while processing this media source. This should not happen and warrants a bug report.', + rejectedLocally: + 'The media source was deemed unsuitable for upload. This may happen if you try to upload a non-audio file.', + rejectedRemotely: + 'The media source was deemed unsuitable by the server processing it. Please contact your administrator if you believe that this is a mistake.', }, }, }, - playlistTable: { + mediaTable: { // Our translation framework automatically picks singular/plural items: '%{smart_count} item |||| %{smart_count} items', assign: 'Assign', @@ -572,7 +588,7 @@ export default { error: { server: { - 'multiple-null-duration-playlist-entries': + 'multiple-null-duration-media-sources': 'There must not be more than one media source without a defined duration. Please set a duration for the existing media sources.', 'no-fallback-show-defined': 'No fallback show has been defined in the radio settings. This must be set for the calendar to be displayed correctly.', @@ -741,8 +757,6 @@ export default { coexistingTimeslot: 'Due to a conflict resolution additional episodes have been created.', addFallbackTitle: 'Add fallback for schedule', - currentlySelectedPlaylist: 'Currently selected playlist', - assignPlaylist: 'Assign playlist', start: 'Start', end: 'End', @@ -790,8 +804,8 @@ export default { calendar: { today: 'Today', - empty: 'No playlist', - fallback: 'Fallback playlist', + empty: 'No media', + fallback: 'Fallback media', repetition: 'Repetition', view: { day: 'Day view', diff --git a/src/steering-types.ts b/src/steering-types.ts index 188d648c..a156ebf4 100644 --- a/src/steering-types.ts +++ b/src/steering-types.ts @@ -1,6 +1,6 @@ /* eslint-disable */ /* - * This file was auto-generated by `make update-types` at 2024-12-19 17:08:31.420Z. + * This file was auto-generated by `make update-types` at 2025-01-23 19:23:45.859Z. * DO NOT make changes to this file. */ @@ -141,6 +141,39 @@ export interface paths { /** Partially update an existing link type. */ patch: operations['link_types_partial_update'] } + '/api/v1/media/': { + /** List all media. */ + get: operations['media_list'] + /** Create a new media. */ + post: operations['media_create'] + } + '/api/v1/media-sources/': { + get: operations['media_sources_list'] + post: operations['media_sources_create'] + } + '/api/v1/media-sources/{id}/': { + get: operations['media_sources_retrieve'] + put: operations['media_sources_update'] + delete: operations['media_sources_destroy'] + patch: operations['media_sources_partial_update'] + } + '/api/v1/media-sources/reorder/': { + /** + * Bulk reorder objects + * @description Reorder multiple objects in a single operation. + */ + patch: operations['media_sources_reorder_partial_update'] + } + '/api/v1/media/{id}/': { + /** Retrieve a single media. */ + get: operations['media_retrieve'] + /** Update an existing media. */ + put: operations['media_update'] + /** Delete an existing media. */ + delete: operations['media_destroy'] + /** Partially update an existing media. */ + patch: operations['media_partial_update'] + } '/api/v1/music-focus/': { /** List all music focuses. */ get: operations['music_focus_list'] @@ -157,22 +190,6 @@ export interface paths { /** Partially update an existing music focus. */ patch: operations['music_focus_partial_update'] } - '/api/v1/playlists/': { - /** List all playlists. */ - get: operations['playlists_list'] - /** Create a new playlist. */ - post: operations['playlists_create'] - } - '/api/v1/playlists/{id}/': { - /** Retrieve a single playlist. */ - get: operations['playlists_retrieve'] - /** Update an existing playlist. */ - put: operations['playlists_update'] - /** Delete an existing playlist. */ - delete: operations['playlists_destroy'] - /** Partially update an existing playlist. */ - patch: operations['playlists_partial_update'] - } '/api/v1/profiles/': { /** List all profiles. */ get: operations['profiles_list'] @@ -386,7 +403,7 @@ export interface components { /** Format: date-time */ end: string timeslotId: number | null - playlistId: number | null + mediaId: number | null showId: number } /** @@ -454,7 +471,8 @@ export interface components { languageIds?: number[] /** @description Array of `Link` objects. */ links?: components['schemas']['EpisodeLink'][] - /** @description Playlist ID. */ + mediaDescription?: string + /** @description `Media` ID. */ mediaId?: number | null /** @description Memo for this episode. */ memo?: string @@ -510,8 +528,8 @@ export interface components { categoryIds: number[] /** @description CBA series ID. */ cbaSeriesId?: number | null - /** @description Default `Playlist` ID for this show. */ - defaultPlaylistId?: number | null + /** @description Default `Media` ID for this show. */ + defaultMediaId?: number | null /** @description Description of this show. */ description?: string /** @@ -583,7 +601,7 @@ export interface components { start: string /** Format: date-time */ end: string - playlistId: number | null + mediaId: number | null showId: number showName: string scheduleId: number @@ -624,7 +642,8 @@ export interface components { languageIds?: number[] /** @description Array of `Link` objects. */ links?: components['schemas']['EpisodeLink'][] - /** @description Playlist ID. */ + mediaDescription?: string + /** @description `Media` ID. */ mediaId?: number | null /** @description Memo for this episode. */ memo?: string @@ -677,7 +696,7 @@ export interface components { /** @description Alternate text for the image. */ altText?: string /** @description `ContentLicense` object. */ - contentLicense?: components['schemas']['ContentLicense'] + contentLicense?: components['schemas']['ContentLicense'] | null /** * Format: uri * @description The URI of the image. @@ -724,6 +743,27 @@ export interface components { /** @description Name of the link type */ name: string } + Media: { + description?: string + entries: readonly components['schemas']['MediaSource'][] + playoutMode?: string + showId: number + id: number + /** Format: date-time */ + createdAt: string + createdBy: string + /** Format: date-time */ + updatedAt: string | null + updatedBy: string + } + MediaSource: { + id: number + mediaId: number + /** Format: double */ + duration?: number | null + fileId?: number | null + uri?: string + } MusicFocus: { id: number /** @description True if music focus is active. */ @@ -765,6 +805,36 @@ export interface components { previous?: string | null results: components['schemas']['Image'][] } + PaginatedMediaList: { + /** @example 123 */ + count: number + /** + * Format: uri + * @example http://api.example.org/accounts/?offset=400&limit=100 + */ + next?: string | null + /** + * Format: uri + * @example http://api.example.org/accounts/?offset=200&limit=100 + */ + previous?: string | null + results: components['schemas']['Media'][] + } + PaginatedMediaSourceList: { + /** @example 123 */ + count: number + /** + * Format: uri + * @example http://api.example.org/accounts/?offset=400&limit=100 + */ + next?: string | null + /** + * Format: uri + * @example http://api.example.org/accounts/?offset=200&limit=100 + */ + previous?: string | null + results: components['schemas']['MediaSource'][] + } PaginatedProfileList: { /** @example 123 */ count: number @@ -853,7 +923,8 @@ export interface components { languageIds?: number[] /** @description Array of `Link` objects. */ links?: components['schemas']['EpisodeLink'][] - /** @description Playlist ID. */ + mediaDescription?: string + /** @description `Media` ID. */ mediaId?: number | null /** @description Memo for this episode. */ memo?: string @@ -889,7 +960,7 @@ export interface components { /** @description Alternate text for the image. */ altText?: string /** @description `ContentLicense` object. */ - contentLicense?: components['schemas']['ContentLicense'] + contentLicense?: components['schemas']['ContentLicense'] | null /** * Format: uri * @description The URI of the image. @@ -936,18 +1007,9 @@ export interface components { /** @description Name of the link type */ name?: string } - PatchedMusicFocus: { - id?: number - /** @description True if music focus is active. */ - isActive?: boolean - /** @description Name of the music focus. */ - name?: string - /** @description Slug of the music focus. */ - slug?: string - } - PatchedPlaylist: { + PatchedMedia: { description?: string - entries?: components['schemas']['PlaylistEntry'][] + entries?: readonly components['schemas']['MediaSource'][] playoutMode?: string showId?: number id?: number @@ -958,6 +1020,23 @@ export interface components { updatedAt?: string | null updatedBy?: string } + PatchedMediaSource: { + id?: number + mediaId?: number + /** Format: double */ + duration?: number | null + fileId?: number | null + uri?: string + } + PatchedMusicFocus: { + id?: number + /** @description True if music focus is active. */ + isActive?: boolean + /** @description Name of the music focus. */ + name?: string + /** @description Slug of the music focus. */ + slug?: string + } PatchedProfile: { /** @description Biography of the profile. */ biography?: string @@ -1001,6 +1080,9 @@ export interface components { interval?: number name?: string } + PatchedReorderRequest: { + orderings?: components['schemas']['ReorderItem'][] + } PatchedScheduleCreateUpdateRequest: { /** @description `Schedule` object. */ schedule?: components['schemas']['ScheduleInRequest'] @@ -1020,18 +1102,14 @@ export interface components { episodes?: { [key: string]: number } - /** @description Array of `Playlist` IDs. */ - playlists?: { - [key: string]: number - } } PatchedShow: { /** @description Array of `Category` IDs. */ categoryIds?: number[] /** @description CBA series ID. */ cbaSeriesId?: number | null - /** @description Default `Playlist` ID for this show. */ - defaultPlaylistId?: number | null + /** @description Default `Media` ID for this show. */ + defaultMediaId?: number | null /** @description Description of this show. */ description?: string /** @@ -1145,30 +1223,11 @@ export interface components { permissions?: readonly string[] profileIds?: number[] } - Playlist: { - description?: string - entries?: components['schemas']['PlaylistEntry'][] - playoutMode?: string - showId: number - id: number - /** Format: date-time */ - createdAt: string - createdBy: string - /** Format: date-time */ - updatedAt: string | null - updatedBy: string - } - PlaylistEntry: { - /** Format: double */ - duration?: number | null - fileId?: number | null - uri?: string - } PlayoutEpisode: { id: number /** @description Title of this episode. */ title?: string - playlistId: number | null + mediaId: number | null } PlayoutProgramEntry: { /** Format: uuid */ @@ -1178,7 +1237,7 @@ export interface components { /** Format: date-time */ end: string timeslotId: number | null - playlistId: number | null + mediaId: number | null showId: number timeslot: components['schemas']['TimeSlot'] | null show: components['schemas']['PlayoutShow'] @@ -1187,15 +1246,15 @@ export interface components { } PlayoutSchedule: { id: number - /** @description Playlist in case a timeslot’s playlist_id of this schedule is empty. */ - defaultPlaylistId: number | null + /** @description Media in case a timeslot’s media_id of this schedule is empty. */ + defaultMediaId: number | null } PlayoutShow: { id: number /** @description Name of this Show. */ name: string - /** @description Playlist in case a timeslot’s playlist_id of this show is empty. */ - defaultPlaylistId: number | null + /** @description Media in case a timeslot’s media_id of this show is empty. */ + defaultMediaId: number | null } Profile: { /** @description Biography of the profile. */ @@ -1315,6 +1374,10 @@ export interface components { readonly website: string } } + ReorderItem: { + id: number + order: number + } Schedule: { /** @description Whether to add add_days_no but skipping the weekends. E.g. if weekday is Friday, the date returned will be the next Monday. */ addBusinessDaysOnly?: boolean @@ -1332,8 +1395,8 @@ export interface components { * * `6` - Sunday */ byWeekday?: components['schemas']['ByWeekdayEnum'] | components['schemas']['NullEnum'] | null - /** @description Playlist in case a timeslot’s playlist_id of this schedule is empty. */ - defaultPlaylistId: number | null + /** @description Media in case a timeslot’s media_id of this schedule is empty. */ + defaultMediaId: number | null /** * Format: time * @description End time of schedule. @@ -1378,9 +1441,6 @@ export interface components { episodes: { [key: string]: number } - playlists: { - [key: string]: number - } schedule: components['schemas']['UnsavedSchedule'] } ScheduleCreateUpdateRequest: { @@ -1402,10 +1462,6 @@ export interface components { episodes?: { [key: string]: number } - /** @description Array of `Playlist` IDs. */ - playlists?: { - [key: string]: number - } } ScheduleDryRunResponse: { created: components['schemas']['DryRunTimeSlot'][] @@ -1429,8 +1485,8 @@ export interface components { * * `6` - Sunday */ byWeekday?: components['schemas']['ByWeekdayEnum'] | components['schemas']['NullEnum'] | null - /** @description Playlist in case a timeslot’s playlist_id of this schedule is empty. */ - defaultPlaylistId: number | null + /** @description Media in case a timeslot’s media_id of this schedule is empty. */ + defaultMediaId: number | null /** @description Whether to simulate the database changes. If true, no database changes will occur. Instead a list of objects that would be created, updated and deleted if dryrun was false will be returned. */ dryrun?: boolean /** @@ -1476,9 +1532,6 @@ export interface components { episodes: { [key: string]: number } - playlists: { - [key: string]: number - } schedule: components['schemas']['Schedule'] } Show: { @@ -1486,8 +1539,8 @@ export interface components { categoryIds: number[] /** @description CBA series ID. */ cbaSeriesId?: number | null - /** @description Default `Playlist` ID for this show. */ - defaultPlaylistId?: number | null + /** @description Default `Media` ID for this show. */ + defaultMediaId?: number | null /** @description Description of this show. */ description?: string /** @@ -1623,8 +1676,8 @@ export interface components { * * `6` - Sunday */ byWeekday?: components['schemas']['ByWeekdayEnum'] | components['schemas']['NullEnum'] | null - /** @description Playlist in case a timeslot’s playlist_id of this schedule is empty. */ - defaultPlaylistId: number | null + /** @description Media in case a timeslot’s media_id of this schedule is empty. */ + defaultMediaId: number | null /** * Format: time * @description End time of schedule. @@ -2560,77 +2613,121 @@ export interface operations { } } } - /** List all music focuses. */ - music_focus_list: { + /** List all media. */ + media_list: { + parameters: { + query?: { + /** @description Return only media that use to the specified file ID(s). */ + containsFileIds?: (number | null)[] + /** @description Return only media matching the specified id(s). */ + ids?: number[] + /** @description Number of results to return per page. */ + limit?: number + /** @description The initial index from which to return the results. */ + offset?: number + /** @description Return only media for the specified show ID. */ + showIds?: number[] + } + } responses: { 200: { content: { - 'application/json': components['schemas']['MusicFocus'][] + 'application/json': components['schemas']['PaginatedMediaList'] } } } } - /** Create a new music focus. */ - music_focus_create: { + /** Create a new media. */ + media_create: { requestBody: { content: { - 'application/x-www-form-urlencoded': components['schemas']['MusicFocus'] - 'multipart/form-data': components['schemas']['MusicFocus'] - 'application/json': components['schemas']['MusicFocus'] + 'application/x-www-form-urlencoded': components['schemas']['Media'] + 'multipart/form-data': components['schemas']['Media'] + 'application/json': components['schemas']['Media'] } } responses: { 201: { content: { - 'application/json': components['schemas']['MusicFocus'] + 'application/json': components['schemas']['Media'] } } } } - /** Retrieve a single music focus. */ - music_focus_retrieve: { + media_sources_list: { + parameters: { + query?: { + /** @description Number of results to return per page. */ + limit?: number + /** @description The initial index from which to return the results. */ + offset?: number + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['PaginatedMediaSourceList'] + } + } + } + } + media_sources_create: { + requestBody: { + content: { + 'application/x-www-form-urlencoded': components['schemas']['MediaSource'] + 'multipart/form-data': components['schemas']['MediaSource'] + 'application/json': components['schemas']['MediaSource'] + } + } + responses: { + 201: { + content: { + 'application/json': components['schemas']['MediaSource'] + } + } + } + } + media_sources_retrieve: { parameters: { path: { - /** @description A unique integer value identifying this music focus. */ + /** @description A unique integer value identifying this media source. */ id: number } } responses: { 200: { content: { - 'application/json': components['schemas']['MusicFocus'] + 'application/json': components['schemas']['MediaSource'] } } } } - /** Update an existing music focus. */ - music_focus_update: { + media_sources_update: { parameters: { path: { - /** @description A unique integer value identifying this music focus. */ + /** @description A unique integer value identifying this media source. */ id: number } } requestBody: { content: { - 'application/x-www-form-urlencoded': components['schemas']['MusicFocus'] - 'multipart/form-data': components['schemas']['MusicFocus'] - 'application/json': components['schemas']['MusicFocus'] + 'application/x-www-form-urlencoded': components['schemas']['MediaSource'] + 'multipart/form-data': components['schemas']['MediaSource'] + 'application/json': components['schemas']['MediaSource'] } } responses: { 200: { content: { - 'application/json': components['schemas']['MusicFocus'] + 'application/json': components['schemas']['MediaSource'] } } } } - /** Delete an existing music focus. */ - music_focus_destroy: { + media_sources_destroy: { parameters: { path: { - /** @description A unique integer value identifying this music focus. */ + /** @description A unique integer value identifying this media source. */ id: number } } @@ -2641,108 +2738,195 @@ export interface operations { } } } - /** Partially update an existing music focus. */ - music_focus_partial_update: { + media_sources_partial_update: { parameters: { path: { - /** @description A unique integer value identifying this music focus. */ + /** @description A unique integer value identifying this media source. */ id: number } } requestBody?: { content: { - 'application/x-www-form-urlencoded': components['schemas']['PatchedMusicFocus'] - 'multipart/form-data': components['schemas']['PatchedMusicFocus'] - 'application/json': components['schemas']['PatchedMusicFocus'] + 'application/x-www-form-urlencoded': components['schemas']['PatchedMediaSource'] + 'multipart/form-data': components['schemas']['PatchedMediaSource'] + 'application/json': components['schemas']['PatchedMediaSource'] } } responses: { 200: { content: { - 'application/json': components['schemas']['MusicFocus'] + 'application/json': components['schemas']['MediaSource'] } } } } - /** List all playlists. */ - playlists_list: { + /** + * Bulk reorder objects + * @description Reorder multiple objects in a single operation. + */ + media_sources_reorder_partial_update: { + requestBody?: { + content: { + 'application/x-www-form-urlencoded': components['schemas']['PatchedReorderRequest'] + 'multipart/form-data': components['schemas']['PatchedReorderRequest'] + 'application/json': components['schemas']['PatchedReorderRequest'] + } + } + responses: { + /** @description No response body */ + 204: { + content: never + } + } + } + /** Retrieve a single media. */ + media_retrieve: { parameters: { - query?: { - /** @description Return only playlists that use to the specified file ID(s). */ - containsFileIds?: (number | null)[] - /** @description Return only playlists for the specified show ID. */ - showIds?: number[] + path: { + /** @description A unique integer value identifying this media. */ + id: number } } responses: { 200: { content: { - 'application/json': components['schemas']['Playlist'][] + 'application/json': components['schemas']['Media'] } } } } - /** Create a new playlist. */ - playlists_create: { + /** Update an existing media. */ + media_update: { + parameters: { + path: { + /** @description A unique integer value identifying this media. */ + id: number + } + } requestBody: { content: { - 'application/x-www-form-urlencoded': components['schemas']['Playlist'] - 'multipart/form-data': components['schemas']['Playlist'] - 'application/json': components['schemas']['Playlist'] + 'application/x-www-form-urlencoded': components['schemas']['Media'] + 'multipart/form-data': components['schemas']['Media'] + 'application/json': components['schemas']['Media'] + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['Media'] + } + } + } + } + /** Delete an existing media. */ + media_destroy: { + parameters: { + path: { + /** @description A unique integer value identifying this media. */ + id: number + } + } + responses: { + /** @description No response body */ + 204: { + content: never + } + } + } + /** Partially update an existing media. */ + media_partial_update: { + parameters: { + path: { + /** @description A unique integer value identifying this media. */ + id: number + } + } + requestBody?: { + content: { + 'application/x-www-form-urlencoded': components['schemas']['PatchedMedia'] + 'multipart/form-data': components['schemas']['PatchedMedia'] + 'application/json': components['schemas']['PatchedMedia'] + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['Media'] + } + } + } + } + /** List all music focuses. */ + music_focus_list: { + responses: { + 200: { + content: { + 'application/json': components['schemas']['MusicFocus'][] + } + } + } + } + /** Create a new music focus. */ + music_focus_create: { + requestBody: { + content: { + 'application/x-www-form-urlencoded': components['schemas']['MusicFocus'] + 'multipart/form-data': components['schemas']['MusicFocus'] + 'application/json': components['schemas']['MusicFocus'] } } responses: { 201: { content: { - 'application/json': components['schemas']['Playlist'] + 'application/json': components['schemas']['MusicFocus'] } } } } - /** Retrieve a single playlist. */ - playlists_retrieve: { + /** Retrieve a single music focus. */ + music_focus_retrieve: { parameters: { path: { - /** @description A unique integer value identifying this playlist. */ + /** @description A unique integer value identifying this music focus. */ id: number } } responses: { 200: { content: { - 'application/json': components['schemas']['Playlist'] + 'application/json': components['schemas']['MusicFocus'] } } } } - /** Update an existing playlist. */ - playlists_update: { + /** Update an existing music focus. */ + music_focus_update: { parameters: { path: { - /** @description A unique integer value identifying this playlist. */ + /** @description A unique integer value identifying this music focus. */ id: number } } requestBody: { content: { - 'application/x-www-form-urlencoded': components['schemas']['Playlist'] - 'multipart/form-data': components['schemas']['Playlist'] - 'application/json': components['schemas']['Playlist'] + 'application/x-www-form-urlencoded': components['schemas']['MusicFocus'] + 'multipart/form-data': components['schemas']['MusicFocus'] + 'application/json': components['schemas']['MusicFocus'] } } responses: { 200: { content: { - 'application/json': components['schemas']['Playlist'] + 'application/json': components['schemas']['MusicFocus'] } } } } - /** Delete an existing playlist. */ - playlists_destroy: { + /** Delete an existing music focus. */ + music_focus_destroy: { parameters: { path: { - /** @description A unique integer value identifying this playlist. */ + /** @description A unique integer value identifying this music focus. */ id: number } } @@ -2753,25 +2937,25 @@ export interface operations { } } } - /** Partially update an existing playlist. */ - playlists_partial_update: { + /** Partially update an existing music focus. */ + music_focus_partial_update: { parameters: { path: { - /** @description A unique integer value identifying this playlist. */ + /** @description A unique integer value identifying this music focus. */ id: number } } requestBody?: { content: { - 'application/x-www-form-urlencoded': components['schemas']['PatchedPlaylist'] - 'multipart/form-data': components['schemas']['PatchedPlaylist'] - 'application/json': components['schemas']['PatchedPlaylist'] + 'application/x-www-form-urlencoded': components['schemas']['PatchedMusicFocus'] + 'multipart/form-data': components['schemas']['PatchedMusicFocus'] + 'application/json': components['schemas']['PatchedMusicFocus'] } } responses: { 200: { content: { - 'application/json': components['schemas']['Playlist'] + 'application/json': components['schemas']['MusicFocus'] } } } diff --git a/src/stores/files.ts b/src/stores/files.ts index 3c1f392f..25d5defb 100644 --- a/src/stores/files.ts +++ b/src/stores/files.ts @@ -7,6 +7,7 @@ import { APIRetrieve, APIUpdate, createExtendableAPI, + BaseRequestOptions, ExtendableAPI, } from '@rokoli/bnb/drf' import { useTimeoutPoll } from '@vueuse/core' @@ -38,6 +39,11 @@ type ParsedImportLogMap = Record<string, ParsedImportLog> export class TimeoutError extends Error {} +export type ImportOptions = BaseRequestOptions & { + onCreate?: (file: TankFile) => unknown + onDone?: (file: TankFile) => unknown +} + function APIUpload(api: ExtendableAPI<TankFile>, pendingImportFileIds: Ref<Set<TankFile['id']>>) { const { create } = APICreate<TankFile, TankFileCreateData>(api) const { partialUpdate } = APIUpdate<TankFile, FileMetadata>(api) @@ -46,7 +52,7 @@ function APIUpload(api: ExtendableAPI<TankFile>, pendingImportFileIds: Ref<Set<T async function waitForImportState( file: TankFile, requiredState: 'running' | 'done', - options: { maxWaitTimeSeconds: number } | undefined = undefined, + options: { signal?: AbortSignal | null; maxWaitTimeSeconds?: number } | undefined = undefined, ) { const maxWaitTimeSeconds = options?.maxWaitTimeSeconds ?? 5 * 60 const startTime = new Date().getTime() / 1000 @@ -61,7 +67,7 @@ function APIUpload(api: ExtendableAPI<TankFile>, pendingImportFileIds: Ref<Set<T showId: file.showId.toString(), }) const response = await fetch( - api.createRequest(api.endpoint(file.id, 'import', query), undefined), + api.createRequest(api.endpoint(file.id, 'import', query), { signal: options?.signal }), ) await api.maybeThrowResponse(response) data = await response.json() @@ -85,8 +91,14 @@ function APIUpload(api: ExtendableAPI<TankFile>, pendingImportFileIds: Ref<Set<T throw new TimeoutError('Maximum wait time passed') } - function _uploadFile(file: File, tankFile: TankFile) { + function _uploadFile( + file: File, + tankFile: TankFile, + options: { signal?: AbortSignal } | undefined = undefined, + ) { return new Promise((resolve, reject) => { + const signal = options?.signal + const flow = new Flow({ ...tankAuthInit.getRequestDefaults(), target: api.endpoint( @@ -98,6 +110,12 @@ function APIUpload(api: ExtendableAPI<TankFile>, pendingImportFileIds: Ref<Set<T chunkSize: 900 * 1024, prioritizeFirstAndLastChunk: true, }) + if (signal) { + signal.addEventListener('abort', () => { + flow.cancel() + reject(signal.reason) + }) + } flow.on('fileSuccess', () => { resolve(tankFile) }) @@ -109,32 +127,33 @@ function APIUpload(api: ExtendableAPI<TankFile>, pendingImportFileIds: Ref<Set<T }) } - type ImportOptions = { - onCreate?: (file: TankFile) => unknown - onDone?: (file: TankFile) => unknown - } - async function uploadFile( file: File, show: Show, options: ImportOptions | undefined = undefined, ) { const url = encodeURI(encodeURI(`upload://${file.name}`)) - const tankFile = await create({ showId: show.id, sourceURI: url }) + const requestOptions = { requestInit: options?.requestInit } + const signal = requestOptions?.requestInit?.signal ?? undefined + + const tankFile = await create({ showId: show.id, sourceURI: url }, requestOptions) options?.onCreate?.(tankFile) - await waitForImportState(tankFile, 'running') + await waitForImportState(tankFile, 'running', { signal }) try { pendingImportFileIds.value.add(tankFile.id) - await _uploadFile(file, tankFile) - await waitForImportState(tankFile, 'done') + await _uploadFile(file, tankFile, { signal }) + await waitForImportState(tankFile, 'done', { signal }) } finally { pendingImportFileIds.value.delete(tankFile.id) } - let importedTankFile = (await retrieve(tankFile.id, { useCached: false })) as TankFile + let importedTankFile = (await retrieve(tankFile.id, { + useCached: false, + ...requestOptions, + })) as TankFile if (!importedTankFile?.metadata?.title) { - importedTankFile = await partialUpdate(tankFile.id, { title: file.name }) + importedTankFile = await partialUpdate(tankFile.id, { title: file.name }, requestOptions) } options?.onDone?.(importedTankFile) return importedTankFile @@ -145,17 +164,23 @@ function APIUpload(api: ExtendableAPI<TankFile>, pendingImportFileIds: Ref<Set<T show: Show, options: ImportOptions | undefined = undefined, ) { - const tankFile = await create({ showId: show.id, sourceURI: url }) + const requestOptions = { requestInit: options?.requestInit } + const signal = requestOptions?.requestInit?.signal + + const tankFile = await create({ showId: show.id, sourceURI: url }, requestOptions) options?.onCreate?.(tankFile) - await waitForImportState(tankFile, 'running') + await waitForImportState(tankFile, 'running', { signal }) try { pendingImportFileIds.value.add(tankFile.id) - await waitForImportState(tankFile, 'done') + await waitForImportState(tankFile, 'done', { signal }) } finally { pendingImportFileIds.value.delete(tankFile.id) } - const importedTankFile = (await retrieve(tankFile.id, { useCached: false })) as TankFile + const importedTankFile = (await retrieve(tankFile.id, { + useCached: false, + ...requestOptions, + })) as TankFile options?.onDone?.(importedTankFile) return importedTankFile } @@ -234,8 +259,10 @@ function parseLog(log: ImportLog): ParsedImportLog { function APIFetchLog(api: ExtendableAPI<TankFile>) { const importLogs = ref(new Map<TankFile['id'], ImportLogMap>()) - async function retrieveLog(id: TankFile['id']) { - const response = await fetch(api.createRequest(createTankURL('files', id, 'logs'), undefined)) + async function retrieveLog(id: TankFile['id'], options?: BaseRequestOptions | undefined) { + const response = await fetch( + api.createRequest(createTankURL('files', id, 'logs'), options?.requestInit), + ) await api.maybeThrowResponse(response) const data = (await response.json()).results as ImportLogMap importLogs.value.set(id, data) diff --git a/src/stores/index.ts b/src/stores/index.ts index 0736ee5e..be8cc79c 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -8,7 +8,8 @@ export { useProfileStore } from '@/stores/profiles' export { useImageStore } from '@/stores/images' export { useLicenseStore } from '@/stores/licenses' export { useEpisodeStore } from '@/stores/episodes' -export { usePlaylistStore } from '@/stores/playlists' +export { useMediaStore } from '@/stores/media' +export { useMediaSourceStore } from '@/stores/media-sources' export { useFilesStore } from '@/stores/files' export { usePlayoutStore } from '@/stores/playout' export { useScheduleStore } from '@/stores/schedules' diff --git a/src/stores/media-manager.ts b/src/stores/media-manager.ts new file mode 100644 index 00000000..f19f96bf --- /dev/null +++ b/src/stores/media-manager.ts @@ -0,0 +1,404 @@ +import { defineStore } from 'pinia' +import { ensureError, makeInsecureRandomId } from '@/util' +import { computed, MaybeRefOrGetter, reactive, ref, toValue, watch } from 'vue' +import { File as TankFile, Media, MediaSourceCreateData, Show } from '@/types' +import { ImportOptions, useFilesStore } from '@/stores/files' +import { useMediaStore } from '@/stores/media' +import { useMediaSourceStore } from '@/stores/media-sources' +import { APIResponseError } from '@rokoli/bnb/drf' + +type FailReason = 'notAnAudioFile' | 'cancelledByUser' | 'unableToUpload' | undefined + +type MediaSourceStatus = + // attached to the queue (initial state) + | { key: 'queued' } + // rejected by the queue processor + | { key: 'rejectedLocally'; reason?: FailReason } + // rejected by the server + | { key: 'rejectedRemotely'; reason?: FailReason } + // job was cancelled (most likely by the user) + | { key: 'cancelled'; reason?: FailReason } + // failed to process by the queue processor (retry possible) + | { key: 'failed'; reason?: FailReason } + // failed to process by the queue processor (retry not possible) + | { key: 'failedPermanently'; reason?: FailReason } + // currently being processed by the queue processor + | { + key: 'processing' + progress?: number + phase?: string + } + +interface FileData { + file: File +} + +interface FileImportData { + importUrl: string +} + +interface URLData { + url: string +} + +export type MediaResolver = (options?: { useCached?: boolean } | undefined) => Promise<Media> + +export interface MediaSourceJob { + id: string + name: string + contextKey: string | null + status: MediaSourceStatus + data: FileData | FileImportData | URLData + controller: AbortController + error: Error | null + show: Show + getMedia: MediaResolver + tankFile: TankFile | null + lastProcessor: MediaSourceJobProcessor | null +} + +type MediaSourceJobProcessor = (job: MediaSourceJob, next: () => void) => Promise<void> + +const ensureFileIsAudio: MediaSourceJobProcessor = async function (job, next) { + if ('file' in job.data && !job.data.file.type.startsWith('audio/')) { + job.status = { key: 'rejectedLocally', reason: 'notAnAudioFile' } + return + } + next() +} + +const importFile: MediaSourceJobProcessor = async (job: MediaSourceJob, next: () => void) => { + if (!('file' in job.data || 'importUrl' in job.data)) return next() + + const fileStore = useFilesStore() + + job.status = { key: 'processing' } + + if (job.tankFile) { + // A tank file already exists for this file, + // which means that this is a retry. + await fileStore.remove(job.tankFile.id) + job.tankFile = null + job.error = null + } + + // While we’re uploading, watch the progress and update the job status. + const progressWatcher = watch( + () => (job.tankFile ? fileStore.importProgressMap.get(job.tankFile.id) ?? null : null), + (progress) => { + if (!progress) return + job.status = { + key: 'processing', + phase: `file:${progress.step}`, + progress: progress.progress, + } + }, + ) + + // These options are shared for uploads and URL imports. + const importOptions: ImportOptions = { + requestInit: { signal: job.controller.signal }, + onDone() { + job.status = { key: 'processing' } + }, + onCreate(file) { + job.tankFile = file + }, + } + + try { + if ('file' in job.data) { + await fileStore.uploadFile(job.data.file, job.show, importOptions) + } else if ('importUrl' in job.data) { + await fileStore.importFileURL(job.data.importUrl, job.show, importOptions) + } + } catch (err) { + if (job.controller.signal.aborted) throw err + job.error = ensureError(err) + job.status = { key: 'failed', reason: 'unableToUpload' } + return + } finally { + progressWatcher.stop() + } + + if (job.tankFile) { + // refresh tank file so that it has the correct duration. + job.tankFile = await fileStore.retrieve((job.tankFile as TankFile).id, { useCached: false }) + } + + next() +} + +async function makeMediaSourceFromJob(job: MediaSourceJob): Promise<MediaSourceCreateData> { + const mediaId = (await job.getMedia()).id + if (job.tankFile) { + return { mediaId, duration: job.tankFile.duration, fileId: job.tankFile.id } + } else if ('url' in job.data) { + return { mediaId, duration: null, uri: job.data.url } + } else { + throw new TypeError('Invalid job type. Cannot convert to media source.') + } +} + +const attachMediaSource: MediaSourceJobProcessor = async function attachMediaSource(job, next) { + const mediaSourceStore = useMediaSourceStore() + job.status = { key: 'processing', phase: 'media:save' } + try { + await mediaSourceStore.create(await makeMediaSourceFromJob(job)) + } catch (e) { + if (e instanceof APIResponseError) { + job.error = e + const { status } = e.response + if (status >= 400 && status < 500) { + job.status = { key: 'failed' } + return + } + } + + throw e + } + next() +} + +const mediaSourceJobProcessors: MediaSourceJobProcessor[] = [ + ensureFileIsAudio, + importFile, + attachMediaSource, +] + +const useMediaSourceManagerStore = defineStore('mediaSourceQueue', () => { + const jobQueueMap = ref(new Map<string, MediaSourceJob>()) + const jobQueue = computed(() => Array.from(jobQueueMap.value.values())) + + function removeJob(id: string) { + jobQueueMap.value.delete(id) + } + + function enqueue( + baseJobData: Pick<MediaSourceJob, 'name' | 'data' | 'contextKey' | 'show' | 'getMedia'>, + ) { + const id = makeInsecureRandomId() + const job = reactive({ + ...baseJobData, + id, + status: { key: 'queued' } as const, + controller: new AbortController(), + error: null, + lastProcessor: null, + tankFile: null, + }) + jobQueueMap.value.set(id, job) + _process(id) + return job + } + + async function dismiss(id: string) { + const job = jobQueueMap.value.get(id) + removeJob(id) + + if (job) { + const filesStore = useFilesStore() + const { controller, tankFile } = job + if (!controller.signal.aborted) { + job.controller.abort() + } + if (tankFile) { + await filesStore.remove(tankFile.id) + } + } + } + + async function startNextProcessor(job: MediaSourceJob) { + const processor = + job.lastProcessor === null + ? mediaSourceJobProcessors[0] + : mediaSourceJobProcessors[mediaSourceJobProcessors.indexOf(job.lastProcessor) + 1] + + if (!processor) { + // If we haven’t found a processor, there’s nothing left to do for this job record. + removeJob(job.id) + return + } + + try { + job.error = null + await processor(job, () => { + job.lastProcessor = processor + process(job.id) + }) + } catch (err) { + if (job.controller.signal.aborted) { + job.status = { key: 'cancelled', reason: job.controller.signal.reason } + } else { + job.error = ensureError(err) + job.status = { key: 'failedPermanently' } + } + } + } + + function canProcess(job: MediaSourceJob) { + const validStates: MediaSourceJob['status']['key'][] = [ + 'queued', + 'processing', + 'failed', + 'cancelled', + ] + return validStates.includes(job.status.key) + } + + function _process(id: MediaSourceJob['id']) { + const job = jobQueueMap.value.get(id) + if (!job) return + + // Don’t process jobs that shouldn’t be processed. + // This is the case for jobs that have been rejected or failed permanently. + if (!canProcess(job)) return + + if (job.controller.signal.aborted) { + // Job processing was aborted before, but because this function + // will only be called in case of a retry, we can safely create a new + // job controller that is used for this re-run. + job.controller = new AbortController() + } + + // We don’t care for the async return state of this function + // as all of its state is handled through properties on + // the job object. + void startNextProcessor(job) + } + + function process(...ids: MediaSourceJob['id'][]) { + for (const id of ids) { + setTimeout(_process.bind(null, id), 0) + } + } + + return { + jobQueueMap, + jobQueue, + process, + dismiss, + enqueue, + } +}) + +export function useMediaFactory( + show: MaybeRefOrGetter<Show>, + media: MaybeRefOrGetter<Media | null | undefined>, + assignMedia: (media: Media) => Promise<unknown>, +): MediaResolver { + const mediaStore = useMediaStore() + return async function getMedia(options) { + const _media: Media | null = toValue(media) ?? null + + if (_media !== null) { + const refreshedMedia = await mediaStore.retrieve(_media.id, options) + if (refreshedMedia) return refreshedMedia + } + + const showId = toValue(show).id + const newMedia = await mediaStore.create({ showId }) + try { + await assignMedia(newMedia) + } catch (err) { + // The media instance didn’t exist before so we should delete it + // if assignment fails. + await mediaStore.remove(newMedia.id) + // We can’t actually handle the assignment error here, + // so we need to re-throw the error. + throw err + } + return newMedia + } +} + +export function useBasicMediaSourceController() { + const store = useMediaSourceManagerStore() + + function dismiss(id: string) { + void store.dismiss(id) + } + + function cancel(id: string, reason: FailReason = 'cancelledByUser') { + const job = store.jobQueueMap.get(id) + if (!job) return + job.controller.abort(reason) + } + + function retry(id: string) { + store.process(id) + } + + return { dismiss, cancel, retry } +} + +export function useMediaSourceController(options: { + show: MaybeRefOrGetter<Show> + getMedia: MediaResolver + contextKey?: MaybeRefOrGetter<string | null | undefined> +}) { + const store = useMediaSourceManagerStore() + const basicOperations = useBasicMediaSourceController() + + function enqueue(name: string, entry: MediaSourceJob['data']) { + return store.enqueue({ + name, + data: entry, + contextKey: toValue(options.contextKey) ?? null, + show: toValue(options.show), + getMedia: options.getMedia, + }) + } + + function addFiles(...files: (FileList | File | File[] | null | undefined)[]) { + const _files = files + .filter((f) => f !== null && f !== undefined) + .flatMap((f) => (f instanceof FileList ? Array.from(f) : f)) as File[] + for (const file of _files) { + enqueue(file.name, { file }) + } + } + + function addImports(...importUrls: string[]) { + for (const importUrl of importUrls) { + const name = importUrl.split('/').at(-1) ?? '' + enqueue(name, { importUrl }) + } + } + + function addUrls(...data: (string | { url: string; name: string })[]) { + for (const item of data) { + if (typeof item === 'string') { + const name = item.split('/').at(-1) ?? '' + enqueue(name, { url: item }) + } else { + enqueue(item.name, { url: item.url }) + } + } + } + + return { + ...basicOperations, + addFiles, + addImports, + addUrls, + } +} + +export function useMediaSourceQueue(contextKey: MaybeRefOrGetter<string | null | undefined>) { + const store = useMediaSourceManagerStore() + + return computed(() => { + const _contextKey = toValue(contextKey) + return _contextKey + ? store.jobQueue.filter((entry) => entry.contextKey === _contextKey) + : Array.from(store.jobQueue) + }) +} + +export function useJobHasState( + job: MaybeRefOrGetter<MediaSourceJob>, + states: MediaSourceJob['status']['key'][], +) { + return computed(() => states.includes(toValue(job).status.key)) +} diff --git a/src/stores/media-sources.ts b/src/stores/media-sources.ts new file mode 100644 index 00000000..af502895 --- /dev/null +++ b/src/stores/media-sources.ts @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia' +import { createSteeringURL } from '@/api' +import { + APICreate, + APIListPaginated, + APIRemove, + APIRetrieve, + APIUpdate, + createExtendableAPI, +} from '@rokoli/bnb/drf' +import { MediaSource as _MediaSource, MediaSourceCreateData, MediaSourceUpdateData } from '@/types' +import { steeringAuthInit } from '@/stores/auth' +import { useMediaStore } from '@/stores/media' +import { aggregateWithIdsParameter, APIReorder } from '@/util/api' + +export const useMediaSourceStore = defineStore('mediaSources', () => { + const endpoint = createSteeringURL.prefix('media-sources') + const { api, base } = createExtendableAPI<_MediaSource>(endpoint, steeringAuthInit) + const listOperations = APIListPaginated(api) + + function refreshMediaFromEvent({ detail }: { detail: { object: _MediaSource | null } }) { + if (!detail.object) return + const mediaStore = useMediaStore() + void mediaStore.retrieve(detail.object.mediaId, { useCached: false }) + } + + api.events.addEventListener('set', refreshMediaFromEvent) + api.events.addEventListener('remove', refreshMediaFromEvent) + + return { + ...base, + ...listOperations, + ...APICreate<_MediaSource, MediaSourceCreateData>(api), + ...APIUpdate<_MediaSource, MediaSourceUpdateData, MediaSourceUpdateData>(api), + ...APIRetrieve(api, { + aggregate: aggregateWithIdsParameter(listOperations.listIsolated), + }), + ...APIReorder(api), + ...APIRemove(api), + } +}) diff --git a/src/stores/media.ts b/src/stores/media.ts new file mode 100644 index 00000000..531362e1 --- /dev/null +++ b/src/stores/media.ts @@ -0,0 +1,105 @@ +import { + APICreate, + APIListPaginated, + APIRemove, + APIRetrieve, + APIUpdate, + createExtendableAPI, +} from '@rokoli/bnb/drf' +import { defineStore } from 'pinia' + +import { createSteeringURL } from '@/api' +import { steeringAuthInit } from '@/stores/auth' +import { Media, MediaSource, MediaCreateData, MediaUpdateData } from '@/types' +import { computed, MaybeRefOrGetter, toValue } from 'vue' +import { aggregateWithIdsParameter } from '@/util/api' + +export function calculateMediaDurationInSeconds(media: Media, skipUnknown?: true): number +export function calculateMediaDurationInSeconds(media: Media, skipUnknown?: false): number | null + +/** + * Calculates the duration of a media container. + * May return null if the media object contains entries with an invalid duration. + * @param media + * @param skipUnknown Whether unknown length entries should be skipped + */ +export function calculateMediaDurationInSeconds(media: Media, skipUnknown = false) { + let duration = 0 + for (const mediaSource of media.entries) { + // mediaSource.duration may be null/NaN if the media source references + // a stream or other resources without an inherent duration + if (typeof mediaSource.duration !== 'number' || isNaN(mediaSource.duration)) { + if (skipUnknown) continue + else return null + } + duration += mediaSource.duration + } + + return duration +} + +export function countUnknownDurations(mediaSources: MediaSource[]) { + let counter = 0 + for (const mediaSource of mediaSources) { + if (typeof mediaSource.duration !== 'number' || isNaN(mediaSource.duration)) counter += 1 + } + return counter +} + +export function useMediaState( + media: MaybeRefOrGetter<Media | null>, + targetDurationSeconds: MaybeRefOrGetter<number>, +) { + return computed(() => { + const _media = toValue(media) + if (!_media) return { state: 'missing' as const } + const _targetDuration = Math.round(toValue(targetDurationSeconds)) + const unknownDurationCount = countUnknownDurations(_media.entries) + let mediaDuration = Math.round(calculateMediaDurationInSeconds(_media, true)) + + // If the media contains just one record of unknown length + // the playout will automatically expand that entry to the remaining + // time that is needed to fill the timeslot. + // We can therefore consider a single entry of unknown duration to fit the required time. + if (unknownDurationCount === 1 && mediaDuration < _targetDuration) { + mediaDuration = _targetDuration + } + + if (unknownDurationCount > 1) + return { + state: 'indeterminate' as const, + duration: mediaDuration, + offset: Math.abs(_targetDuration - mediaDuration), + } + if (mediaDuration < _targetDuration) + return { + state: 'tooShort' as const, + duration: mediaDuration, + offset: _targetDuration - mediaDuration, + } + if (mediaDuration > _targetDuration) + return { + state: 'tooLong' as const, + duration: mediaDuration, + offset: mediaDuration - _targetDuration, + } + + return { state: 'ok' as const } + }) +} + +export const useMediaStore = defineStore('media', () => { + const endpoint = createSteeringURL.prefix('media') + const { api, base } = createExtendableAPI<Media>(endpoint, steeringAuthInit) + const listOperations = APIListPaginated(api) + return { + ...base, + ...listOperations, + ...APICreate<Media, MediaCreateData>(api), + ...APIUpdate<Media, MediaUpdateData, MediaUpdateData>(api), + ...APIRetrieve(api, { + aggregate: aggregateWithIdsParameter(listOperations.listIsolated), + }), + ...APIRemove(api), + } +}) diff --git a/src/stores/playlists.ts b/src/stores/playlists.ts deleted file mode 100644 index a015dbea..00000000 --- a/src/stores/playlists.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - APICreate, - APIListPaginated, - APIRemove, - APIRetrieve, - APIUpdate, - createExtendableAPI, -} from '@rokoli/bnb/drf' -import { defineStore } from 'pinia' - -import { createSteeringURL } from '@/api' -import { steeringAuthInit } from '@/stores/auth' -import { Playlist } from '@/types' -import { computed, MaybeRefOrGetter, toValue } from 'vue' - -export function calculatePlaylistDurationInSeconds(playlist: Playlist, skipUnknown?: true): number -export function calculatePlaylistDurationInSeconds( - playlist: Playlist, - skipUnknown?: false, -): number | null - -/** - * Calculates the duration of a playlist. - * May return null if the playlist contains entries with an invalid duration. - * @param playlist - * @param skipUnknown Whether unknown length entries should be skipped - */ -export function calculatePlaylistDurationInSeconds(playlist: Playlist, skipUnknown = false) { - let duration = 0 - for (const entry of playlist.entries) { - // entry.duration may be null/NaN if the entry references - // a stream or other resources without an inherent duration - if (typeof entry.duration !== 'number' || isNaN(entry.duration)) { - if (skipUnknown) continue - else return null - } - duration += entry.duration - } - - return duration -} - -export function countUnknownDurations(entries: Playlist['entries']) { - let counter = 0 - for (const entry of entries) { - if (typeof entry.duration !== 'number' || isNaN(entry.duration)) counter += 1 - } - return counter -} - -export function usePlaylistState( - playlist: MaybeRefOrGetter<Playlist | null>, - targetDurationSeconds: MaybeRefOrGetter<number>, -) { - return computed(() => { - const _playlist = toValue(playlist) - if (!_playlist) return { state: 'missing' as const } - const _targetDuration = Math.round(toValue(targetDurationSeconds)) - const unknownDurationCount = countUnknownDurations(_playlist.entries) - let playlistDuration = Math.round(calculatePlaylistDurationInSeconds(_playlist, true)) - - // If the playlist contains just one record of unknown length - // the playout will automatically expand that entry to the remaining - // time that is needed to fill the timeslot. - // We can therefore consider a single entry of unknown duration to fit the required time. - if (unknownDurationCount === 1 && playlistDuration < _targetDuration) { - playlistDuration = _targetDuration - } - - if (unknownDurationCount > 1) - return { - state: 'indeterminate' as const, - duration: playlistDuration, - offset: Math.abs(_targetDuration - playlistDuration), - } - if (playlistDuration < _targetDuration) - return { - state: 'tooShort' as const, - duration: playlistDuration, - offset: _targetDuration - playlistDuration, - } - if (playlistDuration > _targetDuration) - return { - state: 'tooLong' as const, - duration: playlistDuration, - offset: playlistDuration - _targetDuration, - } - - return { state: 'ok' as const } - }) -} - -export const usePlaylistStore = defineStore('playlists', () => { - const endpoint = createSteeringURL.prefix('playlists') - const { api, base } = createExtendableAPI<Playlist>(endpoint, steeringAuthInit) - return { - ...base, - ...APIUpdate(api), - ...APIListPaginated(api), - ...APIRetrieve(api), - ...APICreate(api), - ...APIRemove(api), - } -}) diff --git a/src/stores/schedules.ts b/src/stores/schedules.ts index ae802de2..7e3f2094 100644 --- a/src/stores/schedules.ts +++ b/src/stores/schedules.ts @@ -15,7 +15,7 @@ import { computed } from 'vue' import { aggregateWithIdsParameter } from '@/util/api' type SchedulePartialUpdateData = Partial< - Pick<ScheduleCreateData['schedule'], 'lastDate' | 'defaultPlaylistId' | 'isRepetition'> + Pick<ScheduleCreateData['schedule'], 'lastDate' | 'defaultMediaId' | 'isRepetition'> > export const useScheduleStore = defineStore('schedules', () => { diff --git a/src/stores/shows.ts b/src/stores/shows.ts index 47dcde48..23c001ac 100644 --- a/src/stores/shows.ts +++ b/src/stores/shows.ts @@ -36,7 +36,7 @@ export function newShow(): NewShow { isActive: true, isPublic: true, predecessorId: null, - defaultPlaylistId: null, + defaultMediaId: null, cbaSeriesId: null, imageId: null, logoId: null, diff --git a/src/types.ts b/src/types.ts index a2e70982..0f11fa54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,10 +50,20 @@ export type NewImage = Omit<Image, 'id' | 'image' | 'file'> & { file: globalThis export type ImageCreateData = Omit<Image, _ImageReadonlyAttrs> & { image: globalThis.File } export type ImageUpdateData = Omit<Image, _ImageReadonlyAttrs> +type _MediaSource = steeringComponents['schemas']['MediaSource'] +export type MediaSource = Required<_MediaSource> +export type MediaSourceCreateData = + | Pick<MediaSource, 'mediaId' | 'duration' | 'fileId'> + | Pick<MediaSource, 'mediaId' | 'duration' | 'uri'> +export type MediaSourceUpdateData = Partial<MediaSourceCreateData> + +type _Media = steeringComponents['schemas']['Media'] +export type Media = Required<Omit<_Media, 'entries'> & { entries: MediaSource[] }> +export type MediaCreateData = Omit<_Media, ReadonlyAttrs | 'entries'> +export type MediaUpdateData = Partial<MediaCreateData> + export type TimeSlot = Required<steeringComponents['schemas']['TimeSlot']> export type Show = Required<steeringComponents['schemas']['Show']> -export type Playlist = Required<steeringComponents['schemas']['Playlist']> -export type PlaylistEntry = Required<steeringComponents['schemas']['PlaylistEntry']> export type File = Required<tankComponents['schemas']['store.File']> export type FileMetadata = Required<tankComponents['schemas']['store.FileMetadata']> export type Import = Required<tankComponents['schemas']['importer.Job']> diff --git a/src/util/api.ts b/src/util/api.ts index b92c4bb0..f2b81bc1 100644 --- a/src/util/api.ts +++ b/src/util/api.ts @@ -1,5 +1,5 @@ import { APIObject, IsolatedPaginatedListOperation } from '@rokoli/bnb' -import { APIRetrieveAggregator } from '@rokoli/bnb/drf' +import { APIRetrieveAggregator, BaseRequestOptions, ExtendableAPI } from '@rokoli/bnb/drf' type APIRetrieveAggregatorOptions = Parameters<typeof APIRetrieveAggregator>[1] const AGGRAGATE_MAX_QUEUE_COUNT: number = 100 @@ -28,3 +28,26 @@ export function aggregateWithIdsParameter<T extends APIObject>( { ...(config ?? {}), maxQueueCount: queueCount }, ) } + +export function APIReorder<T extends APIObject>(api: ExtendableAPI<T>) { + interface Ordering { + id: T['id'] + order: number + } + + async function reorder( + orderings: Ordering[], + options: BaseRequestOptions | undefined = undefined, + ) { + const res = await fetch( + api.createRequest(api.endpoint('reorder'), options?.requestInit, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderings }), + }), + ) + await api.maybeThrowResponse(res) + } + + return { reorder } +} diff --git a/src/util/index.ts b/src/util/index.ts index 7b3a0536..6aa77164 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -477,6 +477,14 @@ export function makeRandomString(length: number, options?: { alphabet?: string } return result } +export function makeInsecureRandomId() { + try { + return crypto.randomUUID() + } catch (e) { + return makeRandomString(36) + } +} + export function useObjectListFromStore<T extends APIObject>( ids: Ref<T['id'][]>, store: { itemMap: Map<T['id'], T>; retrieveMultiple: RetrieveMultipleOperation<T> }, diff --git a/tailwind.config.js b/tailwind.config.js index 19f5b6c7..3a4b3096 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -12,6 +12,7 @@ module.exports = { addVariant('hocus', ['&:hover', '&:focus-visible']) addVariant('group-hocus', ':merge(.group):is(:hover, :focus-visible) &') addVariant('not-disabled', '&:not(:disabled):not(a:not([href]))') + addVariant('not-open', '&:not(:open, :popover-open)') addUtilities({ '.inset-y-center': { 'inset-block': 0, diff --git a/tests/shows.spec.ts b/tests/shows.spec.ts index 4f49f699..a0c57cc1 100644 --- a/tests/shows.spec.ts +++ b/tests/shows.spec.ts @@ -56,10 +56,10 @@ test.describe('Show media management', () => { test('Can upload file', async ({ page }) => { const fileChooserPromise = page.waitForEvent('filechooser') const soundfile = getFileMetadata('meeresrauschen.opus') - await page.getByTestId('playlist-editor:open-file-dialog').click() + await page.getByTestId('media-editor:open-file-dialog').click() const fileChooser = await fileChooserPromise await fileChooser.setFiles(soundfile.path) - const fileTitle = await page.getByTestId('playlist-entry-editor:file-title').innerText() + const fileTitle = await page.getByTestId('media-source-editor:file-title').innerText() expect(fileTitle).toBe(soundfile.name) }) }) -- GitLab