diff --git a/conftest.py b/conftest.py
index 199f5471250b2108720e00ceb1fd60a52d778ca7..7f5672d3dc54a4cedcb4ad9a477aad0454067858 100644
--- a/conftest.py
+++ b/conftest.py
@@ -14,6 +14,8 @@ from program.models import (
     License,
     LinkType,
     MusicFocus,
+    Playlist,
+    PlaylistEntry,
     Profile,
     RadioSettings,
     RRule,
@@ -34,6 +36,8 @@ from program.tests.factories import (
     LinkTypeFactory,
     MusicFocusFactory,
     OwnerFactory,
+    PlaylistEntryFactory,
+    PlaylistFactory,
     ProfileFactory,
     RadioSettingsFactory,
     RRuleFactory,
@@ -147,6 +151,17 @@ def user_with_note_perms() -> User:
     return UserWithPermissionsFactory.create(user_permissions=permissions)
 
 
+@pytest.fixture
+def user_with_playlist_perms() -> User:
+    """User with add_playlist, change_playlist, delete_playlist permission"""
+
+    permissions = Permission.objects.filter(
+        codename__in=["add_playlist", "change_playlist", "delete_playlist"]
+    )
+
+    return UserWithPermissionsFactory.create(user_permissions=permissions)
+
+
 @pytest.fixture
 def common_api_client1(api_client, common_user1) -> APIClient:
     """Authenticated common user 1 API client"""
@@ -174,6 +189,15 @@ def api_client_note_perms(api_client, user_with_note_perms) -> APIClient:
     api_client.force_authenticate()
 
 
+@pytest.fixture
+def api_client_playlist_perms(api_client, user_with_playlist_perms) -> APIClient:
+    """Authenticated API client for user with {add,change,delete}_playlist permissions"""
+
+    api_client.force_authenticate(user_with_playlist_perms)
+    yield api_client
+    api_client.force_authenticate()
+
+
 @pytest.fixture
 def once_rrule() -> RRule:
     return RRuleFactory(freq=0)
@@ -219,6 +243,16 @@ def owned_show(common_user1, show) -> Show:
     return show
 
 
+@pytest.fixture
+def owned_show_playlist_perms(user_with_playlist_perms, show) -> Show:
+    """Show owned by user with playlist permissions"""
+
+    show.owners.set([user_with_playlist_perms])
+    show.save()
+
+    return show
+
+
 @pytest.fixture
 def owned_show_once_timeslot(common_user1, show, once_schedule) -> TimeSlot:
     """Timeslot of a once schedule for a show owned by a common user"""
@@ -303,6 +337,21 @@ def owner() -> User:
     return OwnerFactory()
 
 
+@pytest.fixture
+def default_playlist(show) -> Playlist:
+    return PlaylistFactory(description="default playlist", show=show)
+
+
+@pytest.fixture
+def playlist(show) -> Playlist:
+    return PlaylistFactory(show=show)
+
+
+@pytest.fixture
+def playlist_entry(playlist) -> PlaylistEntry:
+    return PlaylistEntryFactory(playlist=playlist)
+
+
 @pytest.fixture
 def license_() -> License:
     return LicenseFactory()
diff --git a/program/filters.py b/program/filters.py
index d442c2acc34d4669dcc5c90c4ad08bcf857b82a2..026f2059c0309ebf7928d14d6a0f52cd15ae9dcb 100644
--- a/program/filters.py
+++ b/program/filters.py
@@ -334,3 +334,18 @@ class VirtualTimeslotFilterSet(filters.FilterSet):
     class Meta:
         model = models.TimeSlot
         fields = ["start", "end", "include_virtual"]
+
+
+class PlaylistFilter(filters.FilterSet):
+    contains_file_ids = IntegerInFilter(
+        field_name="entries__file_id",
+        help_text="Return only playlists that use to the specified file ID(s).",
+    )
+    show_ids = IntegerInFilter(
+        field_name="show_id",
+        help_text="Return only playlists for the specified show ID.",
+    )
+
+    class Meta:
+        fields = ("contains_file_ids", "show_ids")
+        model = models.Playlist
diff --git a/program/management/commands/addpermissions.py b/program/management/commands/addpermissions.py
index 745b46f4c184c275b48a62da8d122f2f4fd89dc7..fcfdc354e0abdbd6bfa7f6c5ee915df9a5138754 100644
--- a/program/management/commands/addpermissions.py
+++ b/program/management/commands/addpermissions.py
@@ -20,6 +20,13 @@ PERMISSIONS = {
             ],
         ),
         "default change profile": Permission.objects.filter(codename="change_profile"),
+        "default add/change/delete playlist": Permission.objects.filter(
+            codename__in=[
+                "add_playlist",
+                "change_playlist",
+                "delete_playlist",
+            ]
+        ),
         "custom add media-source": Permission.objects.filter(
             codename__in=[
                 "add__file",
@@ -53,6 +60,13 @@ PERMISSIONS = {
                 "change_timeslot",
             ],
         ),
+        "default add/change/delete playlist": Permission.objects.filter(
+            codename__in=[
+                "add_playlist",
+                "change_playlist",
+                "delete_playlist",
+            ]
+        ),
         "custom add media-source": Permission.objects.filter(
             codename__in=[
                 "add__file",
diff --git a/program/migrations/0118_delete_playlist.py b/program/migrations/0118_delete_playlist.py
new file mode 100644
index 0000000000000000000000000000000000000000..df20e6b217a0c3a554293b2960337d2fab7bfe83
--- /dev/null
+++ b/program/migrations/0118_delete_playlist.py
@@ -0,0 +1,16 @@
+# Generated by Django 4.2.16 on 2024-10-29 20:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0117_alter_timeslot_options"),
+    ]
+
+    operations = [
+        migrations.DeleteModel(
+            name="Playlist",
+        ),
+    ]
diff --git a/program/migrations/0119_playlist.py b/program/migrations/0119_playlist.py
new file mode 100644
index 0000000000000000000000000000000000000000..355f224ceecfb0674ba8f1b235b3d0cd882f6335
--- /dev/null
+++ b/program/migrations/0119_playlist.py
@@ -0,0 +1,48 @@
+# Generated by Django 4.2.16 on 2024-10-29 20:44
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0118_delete_playlist"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Playlist",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("created_at", models.DateTimeField(auto_now_add=True)),
+                ("created_by", models.CharField(default="root", max_length=150)),
+                ("description", models.TextField(blank=True)),
+                ("playout_mode", models.CharField(blank=True, default="linear", max_length=8)),
+                ("updated_at", models.DateTimeField(auto_now=True, null=True)),
+                ("updated_by", models.CharField(blank=True, default="", max_length=150)),
+                (
+                    "show",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="playlists",
+                        to="program.show",
+                    ),
+                ),
+            ],
+            options={
+                "permissions": [
+                    ("add__file", "Can add file media-source"),
+                    ("add__import", "Can add import media-source"),
+                    ("add__line", "Can add line media-source"),
+                    ("add__m3ufile", "Can add m3u media-source"),
+                    ("add__stream", "Can add stream media-source"),
+                ],
+            },
+        ),
+    ]
diff --git a/program/migrations/0120_playlistentry.py b/program/migrations/0120_playlistentry.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd1713a272021c464e8131acb525df920be9105c
--- /dev/null
+++ b/program/migrations/0120_playlistentry.py
@@ -0,0 +1,37 @@
+# Generated by Django 4.2.16 on 2024-10-29 20:56
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0119_playlist"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="PlaylistEntry",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("duration", models.FloatField(null=True)),
+                ("file_id", models.IntegerField(null=True)),
+                ("line_num", models.IntegerField()),
+                ("uri", models.CharField(blank=True, max_length=1024)),
+                (
+                    "playlist",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="entries",
+                        to="program.playlist",
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/program/migrations/0121_remove_schedule_default_playlist_id_and_more.py b/program/migrations/0121_remove_schedule_default_playlist_id_and_more.py
new file mode 100644
index 0000000000000000000000000000000000000000..f6b9aff5391e74714b8c21d421c816a2bf92d38d
--- /dev/null
+++ b/program/migrations/0121_remove_schedule_default_playlist_id_and_more.py
@@ -0,0 +1,44 @@
+# Generated by Django 4.2.16 on 2024-10-29 21:28
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0120_playlistentry"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="schedule",
+            name="default_playlist_id",
+        ),
+        migrations.RemoveField(
+            model_name="show",
+            name="default_playlist_id",
+        ),
+        migrations.AddField(
+            model_name="schedule",
+            name="default_playlist",
+            field=models.ForeignKey(
+                help_text="Playlist in case a timeslot’s playlist_id of this schedule is empty.",
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="+",
+                to="program.playlist",
+            ),
+        ),
+        migrations.AddField(
+            model_name="show",
+            name="default_playlist",
+            field=models.ForeignKey(
+                help_text="Playlist in case a timeslot’s playlist_id of this show is empty.",
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="+",
+                to="program.playlist",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0122_remove_timeslot_playlist_id_timeslot_playlist.py b/program/migrations/0122_remove_timeslot_playlist_id_timeslot_playlist.py
new file mode 100644
index 0000000000000000000000000000000000000000..11cbc55557e638bdbcf5483b10fe2b024d29583b
--- /dev/null
+++ b/program/migrations/0122_remove_timeslot_playlist_id_timeslot_playlist.py
@@ -0,0 +1,30 @@
+# Generated by Django 4.2.16 on 2024-10-29 21:34
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0121_remove_schedule_default_playlist_id_and_more"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="timeslot",
+            name="playlist_id",
+        ),
+        migrations.AddField(
+            model_name="timeslot",
+            name="playlist",
+            field=models.ForeignKey(
+                blank=True,
+                help_text="Playlist for this timeslot.",
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="+",
+                to="program.playlist",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0123_alter_playlist_options.py b/program/migrations/0123_alter_playlist_options.py
new file mode 100644
index 0000000000000000000000000000000000000000..df1001cbc26ee979f3671394e0c8765e20ce97d7
--- /dev/null
+++ b/program/migrations/0123_alter_playlist_options.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.16 on 2024-11-01 19:11
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0122_remove_timeslot_playlist_id_timeslot_playlist"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="playlist",
+            options={
+                "permissions": [
+                    ("add__file", "Can add file media-source"),
+                    ("add__import", "Can add import media-source"),
+                    ("add__line", "Can add line media-source"),
+                    ("add__m3ufile", "Can add m3u media-source"),
+                    ("add__stream", "Can add stream media-source"),
+                    ("create_playlist", "Can create playlist"),
+                    ("update_playlist", "Can update playlist"),
+                ]
+            },
+        ),
+    ]
diff --git a/program/migrations/0124_alter_playlist_options.py b/program/migrations/0124_alter_playlist_options.py
new file mode 100644
index 0000000000000000000000000000000000000000..54a772f7b2668e8c450a7eb0e8ec54c1cbf461f4
--- /dev/null
+++ b/program/migrations/0124_alter_playlist_options.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.2.16 on 2024-11-06 15:46
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0123_alter_playlist_options"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="playlist",
+            options={
+                "permissions": [
+                    ("add__file", "Can add file media-source"),
+                    ("add__import", "Can add import media-source"),
+                    ("add__line", "Can add line media-source"),
+                    ("add__m3ufile", "Can add m3u media-source"),
+                    ("add__stream", "Can add stream media-source"),
+                    ("create_playlist", "Can create playlist"),
+                    ("destroy_playlist", "Can destroy playlist"),
+                    ("update_playlist", "Can update playlist"),
+                ]
+            },
+        ),
+    ]
diff --git a/program/migrations/0125_alter_playlistentry_duration.py b/program/migrations/0125_alter_playlistentry_duration.py
new file mode 100644
index 0000000000000000000000000000000000000000..7a2cb14869746bfe99377e4f4edc49cc1a8037e0
--- /dev/null
+++ b/program/migrations/0125_alter_playlistentry_duration.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.16 on 2024-11-12 16:39
+
+import program.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0124_alter_playlist_options"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="playlistentry",
+            name="duration",
+            field=models.FloatField(
+                null=True, validators=[program.models.validate_positive_duration]
+            ),
+        ),
+    ]
diff --git a/program/migrations/0126_playlistentry_file_id_or_uri.py b/program/migrations/0126_playlistentry_file_id_or_uri.py
new file mode 100644
index 0000000000000000000000000000000000000000..d50754321bf39e9b9425e9ef013ea2152f949fbd
--- /dev/null
+++ b/program/migrations/0126_playlistentry_file_id_or_uri.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.16 on 2024-11-12 19:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0125_alter_playlistentry_duration"),
+    ]
+
+    operations = [
+        migrations.AddConstraint(
+            model_name="playlistentry",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("file_id__isnull", False),
+                    models.Q(("uri", ""), _negated=True),
+                    _connector="OR",
+                ),
+                name="file-id-or-uri",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0127_alter_playlistentry_options_and_more.py b/program/migrations/0127_alter_playlistentry_options_and_more.py
new file mode 100644
index 0000000000000000000000000000000000000000..61fd1beb999ae2c397572831b55d603370a8f1bb
--- /dev/null
+++ b/program/migrations/0127_alter_playlistentry_options_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.16 on 2024-11-15 18:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0126_playlistentry_file_id_or_uri"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="playlistentry",
+            options={"ordering": ["playlist", "order"]},
+        ),
+        migrations.RenameField(
+            model_name="playlistentry",
+            old_name="line_num",
+            new_name="order",
+        ),
+    ]
diff --git a/program/models.py b/program/models.py
index 6984ea9a0dd150487e6a60cd7eedc9673009b79e..23ec5bf24b742c2e49c7182acb35b80da347832b 100644
--- a/program/models.py
+++ b/program/models.py
@@ -238,7 +238,13 @@ class Show(models.Model):
     cba_series_id = models.IntegerField(blank=True, null=True)
     created_at = models.DateTimeField(auto_now_add=True)
     created_by = models.CharField(max_length=150)
-    default_playlist_id = models.IntegerField(blank=True, null=True)
+    default_playlist = models.ForeignKey(
+        "Playlist",
+        help_text="Playlist in case a timeslot’s playlist_id of this show is empty.",
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name="+",
+    )
     description = models.TextField(blank=True, help_text="Description of this show.")
     email = models.EmailField(blank=True, null=True, help_text="Email address of this show.")
     funding_category = models.ForeignKey(
@@ -404,10 +410,12 @@ class Schedule(models.Model):
         ],
         null=True,
     )
-    default_playlist_id = models.IntegerField(
-        blank=True,
+    default_playlist = models.ForeignKey(
+        "Playlist",
+        help_text="Playlist in case a timeslot’s playlist_id of this schedule is empty.",
         null=True,
-        help_text="A tank ID in case the timeslot's playlist_id is empty.",
+        on_delete=models.SET_NULL,
+        related_name="+",
     )
     end_time = models.TimeField(null=True, help_text="End time of schedule.")
     first_date = models.DateField(help_text="Start date of schedule.")
@@ -451,7 +459,14 @@ class Schedule(models.Model):
 class TimeSlot(models.Model):
     end = models.DateTimeField()
     memo = models.TextField(blank=True, help_text="Memo for this timeslot.")
-    playlist_id = models.IntegerField(null=True, help_text="Playlist ID of this timeslot.")
+    playlist = models.ForeignKey(
+        "Playlist",
+        blank=True,
+        help_text="Playlist for this timeslot.",
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name="+",
+    )
     repetition_of = models.ForeignKey(
         "self", blank=True, null=True, on_delete=models.SET_NULL, related_name="repetitions"
     )
@@ -563,6 +578,16 @@ class CBA(models.Model):
 
 
 class Playlist(models.Model):
+    created_at = models.DateTimeField(auto_now_add=True)
+    created_by = models.CharField(max_length=150, default="root")
+    description = models.TextField(blank=True)
+    playout_mode = models.CharField(blank=True, null=False, default="linear", max_length=8)
+    show = models.ForeignKey(
+        "Show", null=False, on_delete=models.CASCADE, related_name="playlists"
+    )
+    updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
+    updated_by = models.CharField(blank=True, default="", max_length=150)
+
     class Meta:
         permissions = [
             ("add__file", "Can add file media-source"),
@@ -570,7 +595,41 @@ class Playlist(models.Model):
             ("add__line", "Can add line media-source"),
             ("add__m3ufile", "Can add m3u media-source"),
             ("add__stream", "Can add stream media-source"),
+            # overrules ownership
+            ("create_playlist", "Can create playlist"),
+            ("destroy_playlist", "Can destroy playlist"),
+            ("update_playlist", "Can update playlist"),
+        ]
+
+    def __str__(self):
+        return f"{self.show.name} - {self.description}" if self.description else self.show.name
+
+
+def validate_positive_duration(value: float) -> None:
+    """Validates that the duration is positive. Raises a Django `ValidationError` if negative."""
+
+    if value < 0.0:
+        raise DjangoValidationError("duration must be positive")
+
+
+class PlaylistEntry(models.Model):
+    duration = models.FloatField(null=True, validators=[validate_positive_duration])
+    file_id = models.IntegerField(null=True)
+    order = models.IntegerField(null=False)
+    playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE, related_name="entries")
+    uri = models.CharField(blank=True, max_length=1024)
+
+    class Meta:
+        constraints = [
+            models.CheckConstraint(
+                check=Q(file_id__isnull=False) | ~Q(uri=""),
+                name="file-id-or-uri",
+            )
         ]
+        ordering = ["playlist", "order"]
+
+    def __str__(self):
+        return f"{self.uri} - {self.duration}" if self.duration else self.uri
 
 
 class ImageAspectRadioField(models.CharField):
diff --git a/program/serializers.py b/program/serializers.py
index 08ecfcb8ab5c565b76daf7710b41ba8c78d0bb57..e9481b51e98b1e21286d2a00ee5fbee7366fbc7f 100644
--- a/program/serializers.py
+++ b/program/serializers.py
@@ -30,6 +30,7 @@ from rest_framework.permissions import exceptions
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.core.exceptions import ObjectDoesNotExist
+from django.db import IntegrityError, transaction
 from django.db.models import Q
 from django.utils import text, timezone
 from program.models import (
@@ -43,6 +44,8 @@ from program.models import (
     MusicFocus,
     Note,
     NoteLink,
+    Playlist,
+    PlaylistEntry,
     Profile,
     ProfileLink,
     ProgramEntry,
@@ -543,8 +546,12 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
     cba_series_id = serializers.IntegerField(
         allow_null=True, required=False, help_text="CBA series ID."
     )
-    default_playlist_id = serializers.IntegerField(
-        allow_null=True, required=False, help_text="Default `Playlist` ID for this show."
+    default_playlist_id = serializers.PrimaryKeyRelatedField(
+        allow_null=True,
+        help_text="Default `Playlist` ID for this show.",
+        queryset=Playlist.objects.all(),
+        required=False,
+        source="default_playlist",
     )
     funding_category_id = serializers.PrimaryKeyRelatedField(
         queryset=FundingCategory.objects.all(),
@@ -678,6 +685,7 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
         validated_data["type"] = validated_data.pop("type")
 
         # optional foreign key
+        validated_data["default_playlist"] = validated_data.pop("default_playlist", None)
         validated_data["image"] = validated_data.pop("image", None)
         validated_data["logo"] = validated_data.pop("logo", None)
         validated_data["predecessor"] = validated_data.pop("predecessor", None)
@@ -741,8 +749,8 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
         if "cba_series_id" in validated_data:
             instance.cba_series_id = validated_data.get("cba_series_id")
 
-        if "default_playlist_id" in validated_data:
-            instance.default_playlist_id = validated_data.get("default_playlist_id")
+        if "default_playlist" in validated_data:
+            instance.default_playlist = validated_data.get("default_playlist")
 
         if "email" in validated_data:
             instance.email = validated_data.get("email")
@@ -873,6 +881,7 @@ class ScheduleInRequestSerializer(ScheduleSerializer):
     def create(self, validated_data):
         """Create and return a new Schedule instance, given the validated data."""
 
+        validated_data["default_playlist"] = validated_data.pop("default_playlist_id")
         validated_data["rrule"] = validated_data.pop("rrule_id")
         validated_data["show"] = validated_data.pop("show_id")
 
@@ -890,8 +899,8 @@ class ScheduleInRequestSerializer(ScheduleSerializer):
         instance.end_time = validated_data.get("end_time", instance.end_time)
         instance.last_date = validated_data.get("last_date", instance.last_date)
         instance.is_repetition = validated_data.get("is_repetition", instance.is_repetition)
-        instance.default_playlist_id = validated_data.get(
-            "default_playlist_id", instance.default_playlist_id
+        instance.default_playlist = validated_data.get(
+            "default_playlist_id", instance.default_playlist
         )
         instance.rrule = validated_data.get("rrule_id", instance.rrule)
         instance.show = validated_data.get("show_id", instance.show)
@@ -986,6 +995,13 @@ class TimeSlotSerializer(serializers.ModelSerializer):
         source="repetition_of",
         help_text="This timeslot is a repetition of `Timeslot` ID.",
     )
+    playlist_id = serializers.PrimaryKeyRelatedField(
+        allow_null=True,
+        help_text="",
+        queryset=Playlist.objects.all(),
+        required=False,
+        source="playlist",
+    )
 
     class Meta:
         model = TimeSlot
@@ -1049,7 +1065,7 @@ class TimeSlotSerializer(serializers.ModelSerializer):
             instance.repetition_of = validated_data.get("repetition_of")
 
         if "playlist_id" in validated_data:
-            instance.playlist_id = validated_data.get("playlist_id")
+            instance.playlist = validated_data.get("playlist_id")
 
         instance.save()
 
@@ -1487,3 +1503,122 @@ class ApplicationStatePurgeSerializer(serializers.Serializer):
             "Selects all models if set to true and no other filters have been set."
         ),
     )
+
+
+class PlaylistSerializer(serializers.ModelSerializer):
+    class PlaylistEntrySerializer(serializers.ModelSerializer):
+        class Meta:
+            model = PlaylistEntry
+            fields = (
+                "duration",
+                "file_id",
+                "uri",
+            )
+
+    entries = PlaylistEntrySerializer(many=True, required=False)
+    show_id = serializers.PrimaryKeyRelatedField(
+        queryset=Show.objects.all(),
+        required=True,
+        source="show",
+    )
+
+    class Meta:
+        model = Playlist
+        read_only_fields = (
+            "id",
+            "created_at",
+            "created_by",
+            "updated_at",
+            "updated_by",
+        )
+        fields = (
+            "description",
+            "entries",
+            "playout_mode",
+            "show_id",
+        ) + read_only_fields
+
+    def create(self, validated_data):
+        """Create a new Playlist instance, given the validated data.
+
+        A ValidationError is raised if a playlist entry is invalid or if multiple null duration
+        entries are present."""
+
+        user = self.context["request"].user
+        user_is_owner = user in validated_data.get("show").owners.all()
+
+        # having the create_playlist permission overrules the ownership
+        if not (user.has_perm("program.create_playlist") or user_is_owner):
+            raise exceptions.PermissionDenied(detail="You are not allowed to create a playlist.")
+
+        entries = validated_data.pop("entries", [])
+
+        with transaction.atomic():
+            playlist = Playlist.objects.create(created_by=user.username, **validated_data)
+
+            for order, entry_data in enumerate(entries, start=1):
+                entry_data.update({"order": order})
+
+                try:
+                    PlaylistEntry.objects.create(playlist=playlist, **entry_data)
+                except IntegrityError:
+                    raise exceptions.ValidationError(
+                        code="playlist-entry-file-id-or-uri",
+                        detail="playlist entries must either have file id or uri.",
+                    )
+
+            if playlist.entries.filter(duration__isnull=True).count() > 1:
+                raise exceptions.ValidationError(
+                    code="multiple-null-duration-playlist-entries",
+                    detail="playlist may only have one entry without duration",
+                )
+
+        return playlist
+
+    def update(self, instance, validated_data):
+        """Update an existing Playlist instance, given the validated data.
+
+        A ValidationError is raised if a playlist entry is invalid or if multiple null duration
+        entries are present."""
+
+        user = self.context["request"].user
+        user_is_owner = user in instance.show.owners.all()
+
+        # having the update_playlist permission overrules the ownership
+        if not (user.has_perm("program.update_playlist") or user_is_owner):
+            raise exceptions.PermissionDenied(
+                detail="You are not allowed to update this playlist."
+            )
+
+        with transaction.atomic():
+            if "description" in validated_data:
+                instance.description = validated_data.pop("description")
+
+            if "playout_mode" in validated_data:
+                instance.playout_mode = validated_data.pop("playout_mode")
+
+            if "entries" in validated_data:
+                if instance.entries.count() > 0:
+                    for entry in instance.entries.all():
+                        entry.delete(keep_parents=True)
+
+                for order, entry_data in enumerate(validated_data.get("entries"), start=1):
+                    entry_data.update({"order": order})
+
+                    try:
+                        PlaylistEntry.objects.create(playlist=instance, **entry_data)
+                    except IntegrityError:
+                        raise exceptions.ValidationError(
+                            code="playlist-entry-file-id-or-uri",
+                            detail="playlist entries must either have file id or uri.",
+                        )
+
+                if instance.entries.filter(duration__isnull=True).count() > 1:
+                    raise exceptions.ValidationError(
+                        code="multiple-null-duration-playlist-entries",
+                        detail="playlist may only have one entry without duration",
+                    )
+
+            instance.save()
+
+        return instance
diff --git a/program/tests/factories.py b/program/tests/factories.py
index da6043228682ecce75072e65fd41b28a94c935ef..6e008fb17daf2b35d8cc5d75ddd9d0adcf13b14d 100644
--- a/program/tests/factories.py
+++ b/program/tests/factories.py
@@ -16,6 +16,8 @@ from program.models import (
     LinkType,
     MusicFocus,
     Note,
+    Playlist,
+    PlaylistEntry,
     Profile,
     RadioSettings,
     RRule,
@@ -182,3 +184,18 @@ class CBAFactory(DjangoModelFactory):
 
     username = Sequence(lambda n: "username_%d" % n)
     user_token = Sequence(lambda n: "user_token_%d" % n)
+
+
+class PlaylistFactory(DjangoModelFactory):
+    class Meta:
+        model = Playlist
+
+
+class PlaylistEntryFactory(DjangoModelFactory):
+    class Meta:
+        model = PlaylistEntry
+
+    duration = 73
+    file_id = 42
+    order = Sequence(lambda n: int(n))
+    uri = f"file://0/{file_id}"
diff --git a/program/tests/test_playlists.py b/program/tests/test_playlists.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b4b656607c53cdb9195aee5f174c4ad2593e427
--- /dev/null
+++ b/program/tests/test_playlists.py
@@ -0,0 +1,114 @@
+import random
+
+import pytest
+
+from conftest import assert_data
+from program.tests.factories import PlaylistFactory
+
+pytestmark = pytest.mark.django_db
+
+
+def url(playlist=None):
+    if playlist:
+        return f"/api/v1/playlists/{playlist.id}/"
+    else:
+        return "/api/v1/playlists/"
+
+
+def playlist_data(owned_show):
+    return {
+        "description": "DESCRIPTION",
+        "show_id": owned_show.id,
+    }
+
+
+def playlist_entries_data(show, file_id, entries=1):
+    return [
+        {
+            "duration": random.uniform(1975, 2024),
+            "file_id": file_id,
+            "order": n + 1,
+            "uri": f"file://{show.id}/{file_id}",
+        }
+        for n in range(entries)
+    ]
+
+
+def test_create_playlist(
+    user_with_playlist_perms, api_client_playlist_perms, owned_show_playlist_perms
+):
+    data = playlist_data(owned_show_playlist_perms)
+
+    response = api_client_playlist_perms.post(url(), data=data)
+
+    assert response.status_code == 201
+
+    assert_data(response, data)
+
+
+def test_retrieve_playlist(user_with_playlist_perms, api_client_playlist_perms, show, playlist):
+    response = api_client_playlist_perms.get(url(playlist=playlist))
+
+    assert response.status_code == 200
+
+
+def test_list_playlists(api_client_playlist_perms, show):
+    PLAYLISTS = 3
+    PlaylistFactory.create_batch(size=PLAYLISTS, show=show)
+
+    response = api_client_playlist_perms.get(url())
+
+    assert response.status_code == 200
+    assert len(response.data) == PLAYLISTS
+
+
+def test_delete_playlist(api_client_playlist_perms, owned_show_playlist_perms, playlist):
+    response = api_client_playlist_perms.delete(url(playlist=playlist))
+
+    print(response.data)
+
+    assert response.status_code == 204
+
+
+def test_update_description(api_client_playlist_perms, owned_show_playlist_perms, playlist):
+    update = {"description": "DESCRIPTION"}
+
+    response = api_client_playlist_perms.patch(url(playlist=playlist), data=update)
+
+    assert response.status_code == 200
+    assert response.data["description"] == update["description"]
+
+
+def test_update_playout_mode(api_client_playlist_perms, owned_show_playlist_perms, playlist):
+    update = {"playout_mode": "LINEAR"}
+
+    response = api_client_playlist_perms.patch(url(playlist=playlist), data=update, format="json")
+    print(response.data)
+    assert response.status_code == 200
+    assert response.data["playout_mode"] == update["playout_mode"]
+
+
+def test_update_entries(api_client_playlist_perms, owned_show_playlist_perms, playlist):
+    playlist_entries = playlist_entries_data(owned_show_playlist_perms, 42, 3)
+
+    update = {"entries": playlist_entries}
+
+    response = api_client_playlist_perms.patch(url(playlist=playlist), data=update, format="json")
+
+    assert response.status_code == 200
+
+    for entry, update in zip(response.data["entries"], update["entries"]):
+        assert entry["duration"] == update["duration"]
+        assert entry["file_id"] == update["file_id"]
+        assert entry["uri"] == update["uri"]
+
+
+def test_clear_entries(
+    api_client_playlist_perms, owned_show_playlist_perms, playlist, playlist_entry
+):
+    update = {"entries": []}
+
+    response = api_client_playlist_perms.patch(url(playlist=playlist), data=update, format="json")
+
+    assert response.status_code == 200
+    assert len(response.data["entries"]) == 0
diff --git a/program/tests/test_schedules.py b/program/tests/test_schedules.py
index 0416a0f56a45ccb7b3e22f8ac8ed3a9fef1f628e..bf06d4b51f0c51a3bee7ccd919aa4f24bfbc9e6e 100644
--- a/program/tests/test_schedules.py
+++ b/program/tests/test_schedules.py
@@ -218,8 +218,8 @@ def test_update_schedule(admin_api_client, once_schedule):
     assert_data(response, update)
 
 
-def test_patch_set_default_playlist_id(admin_api_client, once_schedule):
-    update = {"default_playlist_id": 42}
+def test_patch_set_default_playlist_id(admin_api_client, once_schedule, default_playlist, show):
+    update = {"default_playlist_id": default_playlist.id}
 
     response = admin_api_client.patch(url(schedule=once_schedule), data=update)
 
diff --git a/program/tests/test_shows.py b/program/tests/test_shows.py
index 8d498b8113a5eb30fa67e642ff2a92e26fdc6e3b..e0e1855c48d6217e5a6afc26c39995c87cc997a4 100644
--- a/program/tests/test_shows.py
+++ b/program/tests/test_shows.py
@@ -178,6 +178,29 @@ def test_update_show_forbidden_for_common_user(
     assert response.status_code == 403
 
 
+def test_update_default_playlist_id(admin_api_client, show, default_playlist):
+    update = {"default_playlist_id": default_playlist.id}
+
+    response = admin_api_client.patch(url(show=show), data=update)
+
+    assert response.status_code == 200
+
+    assert response.data["default_playlist_id"] == update["default_playlist_id"]
+
+
+def test_clear_default_playlist_id(admin_api_client, show, default_playlist):
+    show.default_playlist = default_playlist
+    show.save()
+
+    update = {"default_playlist_id": None}
+
+    response = admin_api_client.patch(url(show=show), data=update, format="json")
+
+    assert response.status_code == 200
+
+    assert response.data["default_playlist_id"] == update["default_playlist_id"]
+
+
 def test_redacted_fields_for_unauthenticated_requests(api_client, show):
     response = api_client.get(url(show))
     data = response.json()
diff --git a/program/tests/test_timeslots.py b/program/tests/test_timeslots.py
index af923e819b4c797c963f21d20a41600d6f1492d7..a2f4ad70eed84f68d7e2b9e6d5aea361b908bfb8 100644
--- a/program/tests/test_timeslots.py
+++ b/program/tests/test_timeslots.py
@@ -10,7 +10,12 @@ def url(timeslot=None) -> str:
         return "/api/v1/timeslots/"
 
 
-def timeslot_data() -> dict[str, str | int]:
+def timeslot_data(default_playlist=None) -> dict[str, str | int]:
+    if default_playlist is None:
+        return {
+            "memo": "MEMO",
+            "repetition_of": 1,
+        }
     return {
         "memo": "MEMO",
         "playlist_id": 1,
@@ -50,8 +55,8 @@ def test_update_memo_as_admin(admin_api_client, once_timeslot):
     assert response.status_code == 200
 
 
-def test_update_playlist_id_as_admin(admin_api_client, once_timeslot):
-    update = {"playlist_id": 1}
+def test_update_playlist_id_as_admin(admin_api_client, once_timeslot, default_playlist, show):
+    update = {"playlist_id": default_playlist.id}
 
     response = admin_api_client.patch(url(timeslot=once_timeslot), data=update)
 
@@ -65,8 +70,8 @@ def test_update_repetition_of_as_admin(admin_api_client, once_timeslot):
     assert response.status_code == 200
 
 
-def test_update_as_admin(admin_api_client, once_timeslot):
-    update = timeslot_data()
+def test_update_as_admin(admin_api_client, once_timeslot, default_playlist, show):
+    update = timeslot_data(default_playlist=default_playlist)
 
     response = admin_api_client.put(url(timeslot=once_timeslot), data=update)
 
diff --git a/program/views.py b/program/views.py
index 433b940f8a3442698813da48af001b9cf01aec7e..ce6c317c52f4e616644831659e0f0a231c80fb84 100644
--- a/program/views.py
+++ b/program/views.py
@@ -35,6 +35,7 @@ from rest_framework import decorators
 from rest_framework import filters as drf_filters
 from rest_framework import mixins, permissions, status, views, viewsets
 from rest_framework.pagination import LimitOffsetPagination
+from rest_framework.permissions import exceptions
 from rest_framework.request import Request
 from rest_framework.response import Response
 
@@ -54,6 +55,7 @@ from program.models import (
     LinkType,
     MusicFocus,
     Note,
+    Playlist,
     Profile,
     RadioSettings,
     RRule,
@@ -79,6 +81,7 @@ from program.serializers import (
     LinkTypeSerializer,
     MusicFocusSerializer,
     NoteSerializer,
+    PlaylistSerializer,
     PlayoutProgramEntrySerializer,
     ProfileSerializer,
     RadioSettingsSerializer,
@@ -1648,3 +1651,66 @@ class APIApplicationStateView(TestOperationViewMixin, views.APIView):
             invert_selection=params.validated_data["invert_selection"],
         )
         return Response(status=status.HTTP_200_OK, data=deleted)
+
+
+@extend_schema_view(
+    create=extend_schema(summary="Create a new playlist."),
+    retrieve=extend_schema(summary="Retrieve a single playlist."),
+    update=extend_schema(summary="Update an existing playlist."),
+    partial_update=extend_schema(summary="Partially update an existing playlist."),
+    destroy=extend_schema(summary="Delete an existing playlist."),
+    list=extend_schema(summary="List all playlists."),
+)
+class APIPlaylistViewSet(viewsets.ModelViewSet):
+    filterset_class = filters.PlaylistFilter
+    serializer_class = PlaylistSerializer
+
+    def get_queryset(self):
+        """The queryset is empty if the request is not authenticated. Otherwise, it contains all
+        the playlists."""
+
+        if not self.request.user.is_authenticated:
+            return Playlist.objects.none()
+
+        return Playlist.objects.all()
+
+    def create(self, request, *args, **kwargs):
+        serializer = PlaylistSerializer(
+            context={"request": request},
+            data=request.data,
+        )
+
+        if serializer.is_valid(raise_exception=True):
+            serializer.save()
+
+            return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+    def update(self, request, *args, **kwargs):
+        playlist = self.get_object()
+
+        data = request.data
+        data.update({"show_id": playlist.show_id})  # we already know it at this point
+
+        serializer = PlaylistSerializer(
+            context={"request": request},
+            data=data,
+            instance=playlist,
+        )
+
+        if serializer.is_valid(raise_exception=True):
+            serializer.save()
+
+            return Response(serializer.data, status=status.HTTP_200_OK)
+
+    def destroy(self, request, *args, **kwargs):
+        playlist = self.get_object()
+
+        user = request.user
+        user_is_owner = user in playlist.show.owners.all()
+
+        if not (user.has_perm("program.destroy_playlist") or user_is_owner):
+            raise exceptions.PermissionDenied("You are not allowed to delete this playlist.")
+
+        playlist.delete()
+
+        return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/steering/urls.py b/steering/urls.py
index d863f58424d8ae9f88f2326d531e5b461d8b9d89..194e6993d94c3ed7a94821cca7c677752fda9d7f 100644
--- a/steering/urls.py
+++ b/steering/urls.py
@@ -35,6 +35,7 @@ from program.views import (
     APILinkTypeViewSet,
     APIMusicFocusViewSet,
     APINoteViewSet,
+    APIPlaylistViewSet,
     APIProfileViewSet,
     APIProgramBasicViewSet,
     APIProgramCalendarViewSet,
@@ -72,6 +73,7 @@ router.register(r"settings", APIRadioSettingsViewSet, basename="settings")
 router.register(r"program/basic", APIProgramBasicViewSet, basename="program-basic")
 router.register(r"program/playout", APIProgramPlayoutViewSet, basename="program-playout")
 router.register(r"program/calendar", APIProgramCalendarViewSet, basename="program-calendar")
+router.register(r"playlists", APIPlaylistViewSet, basename="playlists")
 
 urlpatterns = [
     path("openid/", include("oidc_provider.urls", namespace="oidc_provider")),