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