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 }),
+  )
+}