diff --git a/src/Pages/ShowEpisodeDetails.vue b/src/Pages/ShowEpisodeDetails.vue index 345fbf0a6f3efc909704dc6b7605cc2ccb7e8774..83f2b2c583b26081fcd9d0187b363b9729f29422 100644 --- a/src/Pages/ShowEpisodeDetails.vue +++ b/src/Pages/ShowEpisodeDetails.vue @@ -60,6 +60,7 @@ :show="show" :media="media" :get-media="getMedia" + :required-duration-seconds="timeslotDuration" :disabled="disabled || isLoadingTimeslots || hasEpisodeBeenBroadcast" :context-key="`episode-${episode.id}-media`" /> @@ -112,7 +113,7 @@ import ATimeEditInfo from '@/components/generic/ATimeEditInfo.vue' import APermissionGuard from '@/components/generic/APermissionGuard.vue' import AMediaEditor from '@/components/media/AMediaEditor.vue' import AFieldset from '@/components/generic/AFieldset.vue' -import { ensureDate, useObjectListFromStore } from '@/util' +import { calculateDurationSeconds, ensureDate, useObjectListFromStore } from '@/util' import ASpinnerLabel from '@/components/generic/ASpinnerLabel.vue' import { useMediaFactory } from '@/stores/media-manager' import FormGroup from '@/components/generic/FormGroup.vue' @@ -159,6 +160,9 @@ const { objects: timeslots, isLoading: isLoadingTimeslots } = useObjectListFromS timeslotStore, ) const timeslotData = computed(() => timeslots.value.map((ts) => generateTimeslotTimeData(ts))) +const timeslotDuration = computed(() => + props.timeslot ? calculateDurationSeconds(props.timeslot.start, props.timeslot.end) : undefined, +) const ConfirmEpisodeDelete = createTemplatePromise<boolean>() diff --git a/src/components/calendar/ACalendarDayEntry.vue b/src/components/calendar/ACalendarDayEntry.vue index d74b7b44954468b99132cb73e244e46e309ca408..849e5cce535a194fce293b47a1686f834a0fc5e0 100644 --- a/src/components/calendar/ACalendarDayEntry.vue +++ b/src/components/calendar/ACalendarDayEntry.vue @@ -81,18 +81,8 @@ {{ t('calendar.repetition') }} </APill> - <APill - v-if=" - mediaDurationInSeconds !== null && - timeslotDurationInSeconds !== mediaDurationInSeconds - " - class="tw-text-amber-700 tw-text-sm" - > - {{ t('calendar.mismatchedLength') }} - </APill> - - <APill v-if="episode && !episode.mediaId" class="tw-text-amber-700 tw-text-sm"> - {{ t('calendar.fallback') }} + <APill v-if="mediaInheritanceLabel" class="tw-text-amber-700 tw-text-sm"> + {{ mediaInheritanceLabel }} </APill> </template> </div> @@ -110,13 +100,13 @@ 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 { calculateMediaDurationInSeconds, useMediaStore } from '@/stores/media' import { useObjectFromStore } from '@rokoli/bnb/drf' import { useEpisodeStore, useShowStore, useTimeSlotStore } from '@/stores' import { computed } from 'vue' import { useI18n } from '@/i18n' import { formatISO } from 'date-fns' import ATime from '@/components/generic/ATime.vue' +import { useTimeslotMediaCascade } from '@/stores/timeslots' const props = defineProps<{ entry: ProgramEntry @@ -129,7 +119,6 @@ const emit = defineEmits<{ edit: [TimeSlot] }>() const { locale, t } = useI18n() const showStore = useShowStore() const timeslotStore = useTimeSlotStore() -const mediaStore = useMediaStore() const episodeStore = useEpisodeStore() const start = computed(() => ensureDate(props.entry.start)) @@ -138,15 +127,9 @@ const durationInSeconds = computed(() => calculateDurationSeconds(start.value, e const { obj: show } = useObjectFromStore(() => props.entry.showId, showStore) -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) -const timeslotDurationInSeconds = computed(() => - timeslot.value ? calculateDurationSeconds(timeslot.value.start, timeslot.value.end) : null, -) + +const { mediaInheritanceLabel } = useTimeslotMediaCascade(timeslot) const { obj: episode } = useObjectFromStore(() => timeslot.value?.episodeId ?? null, episodeStore) diff --git a/src/components/episode/AEpisodeTableRow.vue b/src/components/episode/AEpisodeTableRow.vue index 211e6294855c891ca7ad1e782a41971add5c315d..70efbf2bb736c367f992fea96d85175c295ad21c 100644 --- a/src/components/episode/AEpisodeTableRow.vue +++ b/src/components/episode/AEpisodeTableRow.vue @@ -14,28 +14,30 @@ {{ time }} </span> </td> - <td class="tw-text-xs"> - <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') }} - </AStatus> + <td> + <AMediaPreview v-if="!isEvaluatingMedia" :media="media" /> + + <p + v-if="mediaInheritanceLabel" + class="tw-mt-1 tw-text-sm tw-text-gray-600 dark:tw-text-neutral-500" + > + {{ mediaInheritanceLabel }} + </p> </td> </tr> </template> <script lang="ts" setup> -import { useObjectFromStore } from '@rokoli/bnb/drf' import { computed, useAttrs } from 'vue' import { useI18n } from '@/i18n' import { Episode } from '@/types' -import { useMediaStore, useTimeSlotStore } from '@/stores' +import { useTimeSlotStore } from '@/stores' import Loading from '@/components/generic/Loading.vue' import { ensureDate, useObjectListFromStore } from '@/util' -import AStatus from '@/components/generic/AStatus.vue' import AEpisodeLink from '@/components/episode/AEpisodeLink.vue' +import { useEpisodeMediaCascade } from '@/stores/episodes' +import AMediaPreview from '@/components/media/AMediaPreview.vue' defineOptions({ inheritAttrs: false, @@ -47,10 +49,12 @@ const props = defineProps<{ const attrs = useAttrs() const { t, locale } = useI18n() -const mediaStore = useMediaStore() const timeslotStore = useTimeSlotStore() -const { obj: media } = useObjectFromStore(() => props.episode.mediaId, mediaStore) +const { media, isEvaluatingMedia, mediaInheritanceLabel } = useEpisodeMediaCascade( + () => props.episode, +) + const { objects: timeslots, isLoading: isLoadingTimeslots } = useObjectListFromStore( () => props.episode.timeslotIds as number[], timeslotStore, diff --git a/src/components/media/AMediaDurationCheck.vue b/src/components/media/AMediaDurationCheck.vue deleted file mode 100644 index 307e864dda9128e40e91a068789a7a4beaf8d08f..0000000000000000000000000000000000000000 --- a/src/components/media/AMediaDurationCheck.vue +++ /dev/null @@ -1,32 +0,0 @@ -<template> - <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(`media.state.${mediaState.state}.description`, { totalTime, offset })" - /> - </AAlert> - </div> -</template> - -<script lang="ts" setup> -import { computed } from 'vue' - -import { useI18n } from '@/i18n' -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 - media: Media -}>() - -const { t } = useI18n() - -const totalTime = computed(() => secondsToDurationString(props.requiredDurationSeconds)) -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 index ec9f12b08d7e13ce9ca817f477ebd3ea80f01d32..b3fb6abf84536259d0853ec0ecb33312c3f57788 100644 --- a/src/components/media/AMediaEditor.vue +++ b/src/components/media/AMediaEditor.vue @@ -1,7 +1,7 @@ <template> <div class="tw-relative" v-bind="attrs"> - <AMediaDurationCheck - v-if="media && media.entries.length > 0 && requiredDurationSeconds > 0" + <AMediaStateCheck + v-if="media" :media="media" :required-duration-seconds="requiredDurationSeconds" class="tw-mb-6" @@ -13,6 +13,9 @@ class="tw-mb-6" :can-sort="!disabled" :can-edit="!disabled" + :calculated-duration-seconds=" + mediaState.state === 'autoExpanded' ? mediaState.offsetSeconds : null + " /> <p v-if="disabled && sources.length === 0" class="tw-m-0"> @@ -53,7 +56,7 @@ {{ t('media.editor.control.selectFiles') }} </button> <APermissionGuard show-permissions="program.add__import"> - <button type="button" class="btn btn-default" @click="importFileFromURL()"> + <button type="button" class="btn btn-default" @click="importFileFromURL"> <icon-formkit-url class="tw-flex-none" /> {{ t('media.editor.control.importFile') }} </button> @@ -64,7 +67,7 @@ </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()"> + <button type="button" class="btn btn-default" @click="addStreamMediaSource"> <icon-solar-play-stream-bold class="tw-flex-none" /> {{ t('media.editor.control.addStream') }} </button> @@ -107,13 +110,14 @@ 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 AMediaStateCheck from '@/components/media/AMediaStateCheck.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' +import { useMediaState } from '@/stores/media' const props = withDefaults( defineProps<{ @@ -122,10 +126,10 @@ const props = withDefaults( disabled?: boolean media?: Media | null contextKey?: string | null | undefined - requiredDurationSeconds?: number + requiredDurationSeconds?: number | undefined }>(), { - requiredDurationSeconds: -1, + requiredDurationSeconds: undefined, contextKey: undefined, media: null, }, @@ -142,6 +146,10 @@ const mediaSourceManager = useMediaSourceController({ contextKey: () => props.contextKey, getMedia: props.getMedia, }) +const mediaState = useMediaState( + () => props.media, + () => props.requiredDurationSeconds, +) const isAllowedToAddFiles = useHasUserPermission(['program.add__file']) function addFiles(files: File[] | FileList | null) { diff --git a/src/components/media/AMediaPreview.vue b/src/components/media/AMediaPreview.vue new file mode 100644 index 0000000000000000000000000000000000000000..aff8c63f5ed1f9ebf64aaecf9d047139ccd42fa0 --- /dev/null +++ b/src/components/media/AMediaPreview.vue @@ -0,0 +1,59 @@ +<template> + <template v-if="mediaState.state.startsWith('invalid.')"> + <AStatus v-if="mediaState.state.startsWith('invalid.')" is-error class="tw-text-sm"> + {{ t(`media.state.${mediaState.state}.shortLabel`) }} + </AStatus> + </template> + <template v-else-if="sources.length > 0"> + <p class="tw-font-medium"> + <template v-if="!hasVariableLengthSources || typeof requiredDurationSeconds === 'number'"> + {{ duration }} + </template> + <template v-else>{{ t('media.labels.variableDuration') }}</template> + </p> + <p>{{ mediaSourceLabel }}</p> + </template> +</template> + +<script lang="ts" setup> +import { Media } from '@/types' +import { useMediaState } from '@/stores/media' +import AStatus from '@/components/generic/AStatus.vue' +import { useI18n } from '@/i18n' +import { computed } from 'vue' +import { determineMediaSourceType } from '@/stores/media-sources' +import { secondsToDurationString } from '@/util' +import { usePlayoutStore } from '@/stores' + +const props = defineProps<{ + media: Media | null + requiredDurationSeconds?: number | undefined +}>() + +const playoutStore = usePlayoutStore() +const { t } = useI18n() + +const mediaState = useMediaState( + () => props.media, + () => props.requiredDurationSeconds, +) +const duration = computed(() => secondsToDurationString(mediaState.value.durationSeconds)) +const sources = computed(() => props.media?.entries ?? []) +const hasVariableLengthSources = computed( + () => sources.value.length > 0 && sources.value.some((source) => source.duration === null), +) +const mediaSourceLabel = computed(() => { + if (sources.value.length !== 1) { + return t('mediaSource.count', { smart_count: sources.value.length }) + } + + const source = sources.value[0] + const sourceType = determineMediaSourceType(source) + if (sourceType !== 'line') return t(`mediaSource.type.${sourceType}`) + + const input = playoutStore.inputs.find((input) => input.uri === source.uri) + if (input) return input.label + + return t('mediaSource.type.line') +}) +</script> diff --git a/src/components/media/AMediaSourceEditor.vue b/src/components/media/AMediaSourceEditor.vue index 015b296caabf80c309ffad230a045598c7f6d1fa..cfeef94aa4ce7cfbbc7cd2a6dfdd320abb454963 100644 --- a/src/components/media/AMediaSourceEditor.vue +++ b/src/components/media/AMediaSourceEditor.vue @@ -60,7 +60,17 @@ <template v-if="typeof mediaSource.duration === 'number'"> {{ secondsToDurationString(mediaSource.duration) }} </template> - <AStatus v-else class="tw-text-xs" rounded is-warning> + <AStatus + v-else-if="typeof calculatedDurationSeconds === 'number'" + class="tw-flex tw-items-center tw-gap-2 !tw-tracking-normal" + is-success + rounded + :title="t('mediaSource.labels.autoDuration')" + > + <icon-material-symbols-light-calculate-outline-rounded class="tw-scale-125" /> + {{ secondsToDurationString(calculatedDurationSeconds) }} + </AStatus> + <AStatus v-else is-success rounded class="!tw-tracking-normal"> {{ t('file.durationUnknown') }} </AStatus> </span> @@ -165,11 +175,13 @@ import AStatus from '@/components/generic/AStatus.vue' const props = withDefaults( defineProps<{ mediaSource: MediaSource + calculatedDurationSeconds?: number | null canSort?: boolean canEdit?: boolean as?: string }>(), { + calculatedDurationSeconds: null, as: 'div', canSort: false, canEdit: true, diff --git a/src/components/media/AMediaSources.vue b/src/components/media/AMediaSources.vue index a3a11848927c44ca1f0d3e30f2b7138da5a50f30..d1b00ba416c5a8d3ada0b723a2b7e0009520e5c7 100644 --- a/src/components/media/AMediaSources.vue +++ b/src/components/media/AMediaSources.vue @@ -3,6 +3,7 @@ <AMediaSourceEditor v-for="source in sources" :key="source.id" + :calculated-duration-seconds="calculatedDurationSeconds" :media-source="source" as="li" :can-sort="sources.length === 1 ? false : canSort" @@ -25,10 +26,12 @@ import { isEqual } from 'lodash' const props = withDefaults( defineProps<{ sources: MediaSource[] + calculatedDurationSeconds?: number | null canSort?: boolean canEdit?: boolean }>(), { + calculatedDurationSeconds: undefined, canSort: true, canEdit: true, }, diff --git a/src/components/media/AMediaStateCheck.vue b/src/components/media/AMediaStateCheck.vue new file mode 100644 index 0000000000000000000000000000000000000000..022c211abe4c1474c2712a9bf6f78d5de5d7b984 --- /dev/null +++ b/src/components/media/AMediaStateCheck.vue @@ -0,0 +1,39 @@ +<template> + <AAlert + v-if="mediaState.state.startsWith('notice.') || mediaState.state.startsWith('invalid.')" + class="!tw-text-base" + :title="t(`media.state.${mediaState.state}.title`)" + :is-error="mediaState.state.startsWith('invalid.')" + > + <SafeHTML + sanitize-preset="inline-noninteractive" + :html="t(`media.state.${mediaState.state}.description`, { totalTime, offset })" + /> + </AAlert> +</template> + +<script lang="ts" setup> +import { computed } from 'vue' + +import { useI18n } from '@/i18n' +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 | undefined + media: Media +}>() + +const { t } = useI18n() + +const totalTime = computed(() => + typeof props.requiredDurationSeconds === 'number' + ? secondsToDurationString(props.requiredDurationSeconds) + : '', +) +const mediaState = useMediaState(() => props.media, props.requiredDurationSeconds) +const offset = computed(() => secondsToDurationString(mediaState.value.offsetSeconds)) +</script> diff --git a/src/components/shows/TimeSlotList.vue b/src/components/shows/TimeSlotList.vue index 5ca6e4d802e30fa6c3ea3523ac7a3bdc73144df7..357f12b62bf1b4208ba9b49a6c50a5565afc8ba6 100644 --- a/src/components/shows/TimeSlotList.vue +++ b/src/components/shows/TimeSlotList.vue @@ -14,7 +14,7 @@ <tr> <th>{{ t('episode.singular') }}</th> <th>{{ t('timeslot.fields.start') }}</th> - <th>{{ t('labels.duration') }}</th> + <th>{{ t('timeslot.labels.duration') }}</th> <th>{{ t('mediaSource.plural') }}</th> <th class="tw-p-0" /> </tr> diff --git a/src/components/shows/TimeSlotRow.vue b/src/components/shows/TimeSlotRow.vue index d7069ca461602f82ae0f739a25093c515b6d11b6..96846589d893fe5e1d872b633a32acc380343708 100644 --- a/src/components/shows/TimeSlotRow.vue +++ b/src/components/shows/TimeSlotRow.vue @@ -73,14 +73,18 @@ {{ secondsToDurationString(duration) }} </td> <td> - <AStatus - class="tw-text-xs" - rounded - :is-warning="mediaState.state !== 'ok'" - :is-success="mediaState.state === 'ok'" + <AMediaPreview + v-if="!isEvaluatingMedia" + :media="media" + :required-duration-seconds="duration" + /> + + <p + v-if="mediaInheritanceLabel" + class="tw-mt-1 tw-text-sm tw-text-gray-600 dark:tw-text-neutral-500" > - {{ t(`media.state.${mediaState.state}.title`) }} - </AStatus> + {{ mediaInheritanceLabel }} + </p> </td> <td class="tw-relative tw-p-0" :class="{ 'tw-w-6': isOnAir || isNextUp }"> <div @@ -98,16 +102,13 @@ </template> <script lang="ts" setup> -import { useObjectFromStore } from '@rokoli/bnb/drf' import { computed, ref, useAttrs, useId } from 'vue' import { useI18n } from '@/i18n' import { TimeSlot } from '@/types' import { usePretty } from '@/mixins/prettyDate' import { calculateDurationSeconds, ensureDate, secondsToDurationString, usePlacement } from '@/util' -import { useEpisodeStore, useMediaStore, useTimeSlotStore } from '@/stores' -import { useMediaState } from '@/stores/media' -import AStatus from '@/components/generic/AStatus.vue' +import { useEpisodeStore, useTimeSlotStore } from '@/stores' import AEpisodeAssignmentManager from '@/components/episode/AEpisodeAssignmentManager.vue' import { useAPIObjectFieldCopy, useCreateBehaviour } from '@/form' import AEpisodeLink from '@/components/episode/AEpisodeLink.vue' @@ -115,6 +116,8 @@ import { useRouter } from 'vue-router' import AMenu from '@/components/generic/AMenu.vue' import AMenuItem from '@/components/generic/AMenuItem.vue' import { useNow } from '@vueuse/core' +import { useTimeslotMediaCascade } from '@/stores/timeslots' +import AMediaPreview from '@/components/media/AMediaPreview.vue' defineOptions({ inheritAttrs: false, @@ -131,7 +134,6 @@ const { t } = useI18n() const router = useRouter() const { prettyDateTime } = usePretty() const timeslotStore = useTimeSlotStore() -const mediaStore = useMediaStore() const episodeStore = useEpisodeStore() const now = useNow() @@ -148,10 +150,10 @@ const menu = ref() const menuButton = ref() const { attrs: menuPlacement } = usePlacement(menuButton, menu) -const { obj: episode } = useObjectFromStore(() => props.timeslot.episodeId, episodeStore) const duration = computed(() => calculateDurationSeconds(props.timeslot.start, props.timeslot.end)) -const { obj: media } = useObjectFromStore(() => episode.value?.mediaId ?? null, mediaStore) -const mediaState = useMediaState(media, duration) +const { media, isEvaluatingMedia, mediaInheritanceLabel } = useTimeslotMediaCascade( + () => props.timeslot, +) const start = computed(() => ensureDate(props.timeslot.start)) const end = computed(() => ensureDate(props.timeslot.end)) diff --git a/src/i18n/de.js b/src/i18n/de.js index 9113fe01f3594e8dd66f47767877bd35d71b80da..c9932c17bda6cb1c209b3ba15a9b62b1aff68c13 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -123,6 +123,7 @@ export default { inFuture: 'Zukünftige', inPast: 'Vergangene', perPage: 'Termine pro Seite', + duration: 'Länge', loading: 'Termine werden geladen', }, }, @@ -130,7 +131,7 @@ export default { media: { singular: 'Medien', plural: 'Medien', - duration: 'Dauer', + duration: 'Länge', editor: { title: 'Medienquellen', isSaving: 'Medienquellen werden gespeichert', @@ -182,25 +183,38 @@ export default { saveLabel: 'M3U hinzufügen', }, }, + labels: { + variableDuration: 'Dynamische Länge', + }, + inheritance: { + schedule: 'Medienquellen von Sendeschema übernommen', + show: 'Medienquellen von Sendereihe übernommen', + fallbackShow: 'Medienquellen von Fallback-Sendereihe übernommen', + }, 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.`, + notice: { + tooShort: { + title: 'Es ist Sendezeit übrig', + shortLabel: 'Ungenutzte Sendezeit', + description: `Die hinterlegten Medienquellen sind um <strong>%{offset}</strong> kürzer als die Dauer des Sendetermins. Wird die Sendezeit nicht ausgenutzt, übernimmt automatisch das Fallback-Programm.`, + }, }, - 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.`, + invalid: { + tooLong: { + title: 'Die Sendezeit ist überschritten', + shortLabel: 'Zu lang', + description: `Die hinterlegten Medienquellen sind um <strong>%{offset}</strong> länger als die Dauer des Sendetermins. Bitte kürze den Sendungsinhalt, damit die Sendung nicht unterbrochen wird.`, + }, + multipleUnknownDurations: { + title: 'Medienquellen mit ungültiger Länge', + shortLabeL: 'Ungültige Medienquellen', + description: `Es darf maximal eine Medienquelle ohne Länge geben. Bitte setze für die betreffenden Medienquellen eine Länge.`, + }, + missing: { + title: 'Keine Medienquellen', + shortLabel: 'Keine Medienquellen', + description: 'Es gibt keinerlei Medienquellen für die Ausspielung im Radio.', + }, }, }, }, @@ -211,6 +225,14 @@ export default { count: '%{smart_count} Medienquelle |||| %{smart_count} Medienquellen', labels: { noneAssigned: 'Es wurden bisher keine Medienquellen zugewiesen.', + autoDuration: 'Automatisch berechnete Länge', + }, + type: { + file: 'Upload', + stream: 'Stream', + m3u: 'M3U', + line: 'Eingang', + unknown: 'Unbekannt', }, }, @@ -456,8 +478,8 @@ export default { url: 'URL', name: 'Dateiname', unnamed: 'Unbenannte Datei', - duration: 'Dauer', - durationUnknown: 'Unbekannte Dauer', + duration: 'Länge', + durationUnknown: 'Dynamische Länge', durationPlaceholder: 'z.B. 2m6s', uploadProgress: 'Fortschritt des Dateiuploads', uploadError: 'Fehler beim Upload', @@ -517,7 +539,7 @@ export default { index: 'Nr', description: 'Beschreibung', entries: 'Einträge', - duration: 'Dauer', + duration: 'Länge', lastEdit: 'Bearbeitet am', type: 'Typ', source: 'Quelle', @@ -607,7 +629,7 @@ export default { error: { server: { '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.', + 'Es darf nicht mehr als eine Medienquelle ohne festgelegte Länge geben. Bitte setze für die bestehenden Medienquellen eine Länge.', '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.', does_not_exist: 'Das Eintrag existiert nicht.', @@ -776,7 +798,7 @@ export default { start: 'Start', end: 'Ende', - projectedDuration: '<b>Geplante Dauer:</b> %{duration}', + projectedDuration: '<b>Geplante Länge:</b> %{duration}', pastEventWarning: 'Bitte setze ein Startdatum in der Zukunft.', from: 'Startzeit', diff --git a/src/i18n/en.js b/src/i18n/en.js index 3868a5d1acaf0fb7f9ee5344d66f26fd080facbf..250c40c4889afc4249b41cacdd22e621fad762b0 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -122,6 +122,7 @@ export default { inFuture: 'Future', inPast: 'Past', perPage: 'Dates per page', + duration: 'Length', loading: 'Broadcast dates are being loaded', }, }, @@ -130,7 +131,7 @@ export default { singular: 'media', plural: 'media', - duration: 'Duration', + duration: 'Length', editor: { title: 'Media sources', isSaving: 'Media sources are being saved', @@ -182,25 +183,39 @@ export default { saveLabel: 'Add M3U', }, }, + labels: { + variableLength: 'Dynamic length', + }, + inheritance: { + fromSchedule: 'Media sources inherited from schedule', + fromShow: 'Media sources inherited from show', + fromFallbackShow: 'Media sources inherited from fallback show', + }, 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.`, + notice: { + tooShort: { + title: 'There is airtime left', + shortLabel: 'Unused airtime', + description: `The media sources are <strong>%{offset}</strong> shorter than the duration of the broadcast. If the airtime is not used, the fallback programme automatically takes over.`, + }, }, - 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.`, + invalid: { + tooLong: { + title: 'The airtime is exceeded', + shortLabel: 'Too long', + description: `The media sources are <strong>%{offset}</strong> longer than the duration of the broadcast. Please shorten the media sources so that the episode is not interrupted.`, + }, + multipleUnknownDurations: { + title: 'Media sources with invalid length', + shortLabeL: 'Invalid media sources', + description: + 'There may be a maximum of one media source without a length. Please set a length for the relevant media sources.', + }, + missing: { + title: 'No media sources', + shortLabel: 'No media sources', + description: 'There are no media sources for radio playout.', + }, }, }, }, @@ -211,6 +226,14 @@ export default { count: '%{smart_count} media source |||| %{smart_count} media sources', labels: { noneAssigned: 'No media sources have been assigned yet.', + autoDuration: 'Automatically calculated length', + }, + type: { + file: 'Upload', + stream: 'Stream', + m3u: 'M3U', + line: 'Input', + unknown: 'Unknown', }, }, @@ -454,8 +477,8 @@ export default { url: 'URL', name: 'Filename', unnamed: 'Unnamed file', - duration: 'Duration', - durationUnknown: 'Unknown duration', + duration: 'Length', + durationUnknown: 'Dynamic length', durationPlaceholder: 'e.g. 2m6s', uploadProgress: 'Progress of the file upload', uploadError: 'Error during upload', @@ -513,7 +536,7 @@ export default { index: 'Nr', description: 'Description', entries: 'Entries', - duration: 'Duration', + duration: 'Length', lastEdit: 'Last edited', type: 'Type', source: 'Source', @@ -603,7 +626,7 @@ export default { error: { server: { '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.', + 'There must not be more than one media source without a defined length. Please set a length 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.', does_not_exist: 'The entry does not exist.', @@ -774,7 +797,7 @@ export default { start: 'Start', end: 'End', - projectedDuration: '<b>Projected duration:</b> %{duration}', + projectedDuration: '<b>Projected length:</b> %{duration}', pastEventWarning: 'Please set a start date in the future.', from: 'Start time', diff --git a/src/stores/episodes.ts b/src/stores/episodes.ts index 47641efc6574448dd78923e0910407ff8a819286..1067e3315bb0bdd7041957613dadb1eb7352aa25 100644 --- a/src/stores/episodes.ts +++ b/src/stores/episodes.ts @@ -5,6 +5,7 @@ import { APIRetrieve, APIUpdate, createExtendableAPI, + useObjectFromStore, } from '@rokoli/bnb/drf' import { defineStore } from 'pinia' @@ -12,6 +13,9 @@ import { createSteeringURL } from '@/api' import { steeringAuthInit } from '@/stores/auth' import { Episode, EpisodeCreateData, EpisodeUpdateData } from '@/types' import { aggregateWithIdsParameter } from '@/util/api' +import { MaybeRefOrGetter, toValue } from 'vue' +import { useFallbackShow, useShowStore } from '@/stores/shows' +import { useMediaCascade } from '@/stores/media' export const useEpisodeStore = defineStore('episodes', () => { const endpoint = createSteeringURL.prefix('episodes') @@ -28,3 +32,16 @@ export const useEpisodeStore = defineStore('episodes', () => { ...APIRemove(api), } }) + +export function useEpisodeMediaCascade(episode: MaybeRefOrGetter<Episode | null>) { + const showStore = useShowStore() + + const { obj: show } = useObjectFromStore(() => toValue(episode)?.showId ?? null, showStore) + const { obj: fallback } = useFallbackShow() + + return useMediaCascade( + () => ({ mediaId: toValue(episode)?.mediaId }), + () => ({ mediaId: show.value?.defaultMediaId, inheritedFrom: 'show' as const }), + () => ({ mediaId: fallback.value?.defaultMediaId, inheritedFrom: 'fallbackShow' as const }), + ) +} diff --git a/src/stores/media-sources.ts b/src/stores/media-sources.ts index af502895f0344e3a8d6db2e34dbfef72aa151209..d6af144eaeb7d8dfc5663d04ffeb7e58efb950bd 100644 --- a/src/stores/media-sources.ts +++ b/src/stores/media-sources.ts @@ -13,6 +13,24 @@ import { steeringAuthInit } from '@/stores/auth' import { useMediaStore } from '@/stores/media' import { aggregateWithIdsParameter, APIReorder } from '@/util/api' +export function determineMediaSourceType(mediaSource: _MediaSource) { + if (mediaSource.fileId !== null) return 'file' as const + + let url + try { + url = new URL(mediaSource.uri.toLowerCase()) + } catch (e) { + return 'unknown' as const + } + + const protocol = url.protocol + if (protocol === 'm3u:') return 'm3u' as const + if (protocol === 'line:') return 'line' as const + if (protocol === 'http:' || protocol === 'https:') return 'stream' as const + + return 'unknown' as const +} + export const useMediaSourceStore = defineStore('mediaSources', () => { const endpoint = createSteeringURL.prefix('media-sources') const { api, base } = createExtendableAPI<_MediaSource>(endpoint, steeringAuthInit) diff --git a/src/stores/media.ts b/src/stores/media.ts index 531362e13db0f518b0e44720561b3bcb896a57de..5d1b21716af7dcfd2953ab3de1f23d4698be4966 100644 --- a/src/stores/media.ts +++ b/src/stores/media.ts @@ -5,27 +5,36 @@ import { APIRetrieve, APIUpdate, createExtendableAPI, + useObjectFromStore, } 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 { computed, ComputedRef, MaybeRefOrGetter, toValue } from 'vue' import { aggregateWithIdsParameter } from '@/util/api' +import { useI18n } from '@/i18n' +import { computedAsync } from '@vueuse/core' -export function calculateMediaDurationInSeconds(media: Media, skipUnknown?: true): number -export function calculateMediaDurationInSeconds(media: Media, skipUnknown?: false): number | null +export function calculateMediaDurationInSeconds( + mediaSources: MediaSource[], + skipUnknown?: true, +): number +export function calculateMediaDurationInSeconds( + mediaSources: MediaSource[], + 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 + * May return null if the media object contains entries with unset duration. + * @param mediaSources * @param skipUnknown Whether unknown length entries should be skipped */ -export function calculateMediaDurationInSeconds(media: Media, skipUnknown = false) { +export function calculateMediaDurationInSeconds(mediaSources: MediaSource[], skipUnknown = false) { let duration = 0 - for (const mediaSource of media.entries) { + for (const mediaSource of mediaSources) { // 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)) { @@ -46,45 +55,128 @@ export function countUnknownDurations(mediaSources: MediaSource[]) { return counter } +type MediaCascadeSource = 'episode' | 'schedule' | 'show' | 'fallbackShow' + +type MediaCascadeDescriptor<T extends MediaCascadeSource> = { + mediaId: Media['id'] | null | undefined + inheritedFrom?: T +} + +export function useMediaCascade<T extends MediaCascadeSource>( + ...cascadeDescriptors: MaybeRefOrGetter<MediaCascadeDescriptor<T>>[] +) { + const mediaStore = useMediaStore() + + const cascade = computedAsync( + async () => { + const descriptors = cascadeDescriptors.map((descriptor) => toValue(descriptor)) + const isEvaluating = + descriptors.length > 0 && descriptors.some((descriptor) => descriptor.mediaId === undefined) + + // prefetch media + await mediaStore.retrieveMultiple( + descriptors.filter((d) => typeof d.mediaId === 'number').map((d) => d.mediaId) as number[], + { useCached: true }, + ) + + for (const { mediaId, inheritedFrom } of descriptors) { + if (typeof mediaId !== 'number') continue + const media = await mediaStore.retrieve(mediaId, { useCached: true }) + if (!media) continue + if (media.entries.length === 0) continue + return { mediaId, isEvaluating, inheritedFrom: inheritedFrom } + } + + return { mediaId: null, isEvaluating, inheritedFrom: null } + }, + { mediaId: null, isEvaluating: true, inheritedFrom: null }, + ) + + const { obj: media, isLoading: isLoadingMedia } = useObjectFromStore( + () => cascade.value.mediaId, + mediaStore, + ) + const isEvaluatingMedia = computed(() => cascade.value.isEvaluating || isLoadingMedia.value) + const mediaInheritedFrom = computed(() => cascade.value.inheritedFrom ?? null) + const mediaInheritanceLabel = computed(() => { + if (isEvaluatingMedia.value) return undefined + if (!mediaInheritedFrom.value) return undefined + const { t } = useI18n() + return t(`media.inheritance.${mediaInheritedFrom.value}`) + }) + + return { media, isEvaluatingMedia, mediaInheritedFrom, mediaInheritanceLabel } +} + +type MediaState = + | 'ok' + | 'autoExpanded' + | 'invalid.missing' + | 'invalid.multipleUnknownDurations' + | 'invalid.tooLong' + | 'notice.tooShort' + export function useMediaState( media: MaybeRefOrGetter<Media | null>, - targetDurationSeconds: MaybeRefOrGetter<number>, -) { + targetDurationSeconds?: MaybeRefOrGetter<number | undefined> | undefined, +): ComputedRef<{ state: MediaState; durationSeconds: number; offsetSeconds: 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)) + const sources = toValue(media)?.entries - // 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 (sources === undefined) + return { state: 'invalid.missing', durationSeconds: 0, offsetSeconds: 0 } + + const targetDuration = toValue(targetDurationSeconds) + const roundedTargetDuration = + typeof targetDuration === 'number' ? Math.round(targetDuration) : null + + const unknownDurationCount = countUnknownDurations(sources) + if (unknownDurationCount > 1) { + // This should never happen because steering validates that we don’t have more than one + // source of unknown duration, but better safe than sorry. + return { + state: 'invalid.multipleUnknownDurations', + durationSeconds: 0, + offsetSeconds: 0, + } } - if (unknownDurationCount > 1) + const staticMediaDuration = Math.round(calculateMediaDurationInSeconds(sources, true)) + if (roundedTargetDuration !== null && staticMediaDuration > roundedTargetDuration) { return { - state: 'indeterminate' as const, - duration: mediaDuration, - offset: Math.abs(_targetDuration - mediaDuration), + state: 'invalid.tooLong', + durationSeconds: staticMediaDuration, + offsetSeconds: staticMediaDuration - roundedTargetDuration, } - if (mediaDuration < _targetDuration) + } + + // 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 && roundedTargetDuration !== null) { return { - state: 'tooShort' as const, - duration: mediaDuration, - offset: _targetDuration - mediaDuration, + state: 'autoExpanded', + durationSeconds: roundedTargetDuration, + offsetSeconds: Math.round(roundedTargetDuration - staticMediaDuration), } - if (mediaDuration > _targetDuration) + } + + // There aren’t any sources that are auto-expanded, so if the static media duration is + // too short, and we have a target duration to reach, the sources won’t fill them entirely. + if (roundedTargetDuration !== null && staticMediaDuration < roundedTargetDuration) { return { - state: 'tooLong' as const, - duration: mediaDuration, - offset: mediaDuration - _targetDuration, + state: 'notice.tooShort', + durationSeconds: staticMediaDuration, + offsetSeconds: roundedTargetDuration - staticMediaDuration, } + } - return { state: 'ok' as const } + return { + state: 'ok', + durationSeconds: staticMediaDuration, + offsetSeconds: 0, + } }) } diff --git a/src/stores/shows.ts b/src/stores/shows.ts index 5a0f817461b5c54c8805ad05b98d6ae40acfd4c6..8e2147dbfb36feef9a407c42eb972787ad583f20 100644 --- a/src/stores/shows.ts +++ b/src/stores/shows.ts @@ -6,6 +6,7 @@ import { APIUpdate, createExtendableAPI, ExtendableAPI, + useObjectFromStore, } from '@rokoli/bnb/drf' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' @@ -16,6 +17,7 @@ import { components } from '@/steering-types' import { steeringAuthInit, useAuthStore, useOnAuthBehaviour } from '@/stores/auth' import { KeysFrom, Show } from '@/types' import { aggregateWithIdsParameter } from '@/util/api' +import { useCurrentRadioSettings } from '@/stores/radio-settings' type ReadonlyAttrs = KeysFrom<Show, 'id' | 'createdAt' | 'createdBy' | 'updatedAt' | 'updatedBy'> type ShowCreateData = Omit<Show, ReadonlyAttrs | 'slug'> & { slug?: string } @@ -89,6 +91,12 @@ function useSelectedShowBehaviour(api: ExtendableAPI<Show>) { return { selectedShowId, selectedShow } } +export function useFallbackShow() { + const showStore = useShowStore() + const radioSettings = useCurrentRadioSettings() + return useObjectFromStore(() => radioSettings.value?.program?.fallback?.showId ?? null, showStore) +} + export const useShowStore = defineStore('shows', () => { const endpoint = createSteeringURL.prefix('shows') const { api, base } = createExtendableAPI<Show>(endpoint, steeringAuthInit) diff --git a/src/stores/timeslots.ts b/src/stores/timeslots.ts index 65d71bbe88fd069a9314509fbfd9ac8e67f6fa6a..291e3ea48e5e605ffd14202625c742d567c17fea 100644 --- a/src/stores/timeslots.ts +++ b/src/stores/timeslots.ts @@ -5,6 +5,7 @@ import { APIUpdate, createExtendableAPI, ExtendableAPI, + useObjectFromStore, } from '@rokoli/bnb/drf' import { defineStore } from 'pinia' @@ -13,6 +14,10 @@ import { steeringAuthInit } from '@/stores/auth' import { Episode, KeysFrom, TimeSlot } from '@/types' import { aggregateWithIdsParameter } from '@/util/api' import { useEpisodeStore } from '@/stores/episodes' +import { useFallbackShow, useShowStore } from '@/stores/shows' +import { useScheduleStore } from '@/stores/schedules' +import { MaybeRefOrGetter, toValue } from 'vue' +import { useMediaCascade } from '@/stores/media' type ReadonlyAttrs = KeysFrom< TimeSlot, @@ -61,3 +66,27 @@ function useEpisodeUpdateCascadeBehaviour(api: ExtendableAPI<TimeSlot>) { setTimeout(() => void fetchUpdates([newEpisodeId, oldEpisodeId]), 0) }) } + +export function useTimeslotMediaCascade(timeslot: MaybeRefOrGetter<TimeSlot | null>) { + const showStore = useShowStore() + const scheduleStore = useScheduleStore() + const episodeStore = useEpisodeStore() + + const { obj: episode } = useObjectFromStore( + () => toValue(timeslot)?.episodeId ?? null, + episodeStore, + ) + const { obj: schedule } = useObjectFromStore( + () => toValue(timeslot)?.scheduleId ?? null, + scheduleStore, + ) + const { obj: show } = useObjectFromStore(() => toValue(timeslot)?.showId ?? null, showStore) + const { obj: fallback } = useFallbackShow() + + return useMediaCascade( + () => ({ mediaId: episode.value?.mediaId }), + () => ({ mediaId: schedule.value?.defaultMediaId, inheritedFrom: 'schedule' as const }), + () => ({ mediaId: show.value?.defaultMediaId, inheritedFrom: 'show' as const }), + () => ({ mediaId: fallback.value?.defaultMediaId, inheritedFrom: 'fallbackShow' as const }), + ) +}