diff --git a/program/filters.py b/program/filters.py
index 55ec829e80b05ee1a35191a8a123401df6119c38..60bbce5de6a0d766cbbc3bf807ac39d35d551041 100644
--- a/program/filters.py
+++ b/program/filters.py
@@ -8,6 +8,7 @@ from django import forms
 from django.db.models import Exists, OuterRef, QuerySet
 from django.utils import timezone
 from program import models
+from program.services import generate_program_entries
 
 
 class StaticFilterHelpTextMixin:
@@ -280,3 +281,34 @@ class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
         fields = [
             "is_active",
         ]
+
+
+class VirtualTimeslotFilterSet(filters.FilterSet):
+    start = filters.IsoDateTimeFilter(method="filter_noop")
+    end = filters.IsoDateTimeFilter(method="filter_noop")
+    include_virtual = filters.BooleanFilter(
+        help_text="Include virtual timeslot entries (default: false).",
+        method="filter_noop",
+    )
+
+    # Filters using the noop are implemented in the generate_program_entries generator.
+    # We do this, so that we have all the bells and whistles of the automatic value conversion,
+    # but can still implement custom filter logic on top.
+    def filter_noop(self, queryset: QuerySet, _: str, value: bool) -> QuerySet:
+        return queryset
+
+    def filter_queryset(self, queryset: QuerySet):
+        queryset = super().filter_queryset(queryset)
+        filter_data = self.form.cleaned_data
+        return list(
+            generate_program_entries(
+                queryset,
+                start=filter_data["start"],
+                end=filter_data["end"],
+                include_virtual=filter_data["include_virtual"],
+            )
+        )
+
+    class Meta:
+        model = models.TimeSlot
+        fields = ["start", "end", "include_virtual"]
diff --git a/program/serializers.py b/program/serializers.py
index 9f052dd0e03997dbf8faed876a932995ed80a81c..b8e23db578cddd7a221c6175638f9ae76f95caa5 100644
--- a/program/serializers.py
+++ b/program/serializers.py
@@ -1283,3 +1283,18 @@ class RadioSettingsSerializer(serializers.ModelSerializer):
             logo=logo,
             website=obj.station_website,
         )
+
+
+class ProgramEntrySerializer(serializers.Serializer):
+    id = serializers.CharField()
+    start = serializers.DateTimeField()
+    end = serializers.DateTimeField()
+    timeslot_id = serializers.IntegerField(allow_null=True, source="timeslot.id")
+    show_id = serializers.IntegerField(source="show.id")
+
+
+class ExtendedProgramEntrySerializer(ProgramEntrySerializer):
+    show = ShowSerializer()
+    timeslot = TimeSlotSerializer(allow_null=True)
+    episode = NoteSerializer(allow_null=True, source="timeslot.note")
+    schedule = ScheduleSerializer(allow_null=True, source="timeslot.schedule")
diff --git a/program/services.py b/program/services.py
index 7af891b9a72f073a405bc6b9f06adcc7e5140faf..39efd7109233724fa693bdfb7ce47e0c52c33f2d 100644
--- a/program/services.py
+++ b/program/services.py
@@ -18,7 +18,9 @@
 #
 
 import copy
+from collections.abc import Iterator
 from datetime import datetime, time, timedelta
+from typing import TypedDict
 
 from dateutil.relativedelta import relativedelta
 from dateutil.rrule import rrule
@@ -29,7 +31,15 @@ from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q, QuerySet
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
-from program.models import Note, RRule, Schedule, ScheduleConflictError, Show, TimeSlot
+from program.models import (
+    Note,
+    RadioSettings,
+    RRule,
+    Schedule,
+    ScheduleConflictError,
+    Show,
+    TimeSlot,
+)
 from program.serializers import ScheduleSerializer, TimeSlotSerializer
 from program.typing import Conflicts, ScheduleCreateUpdateData, ScheduleData
 from program.utils import parse_date, parse_datetime, parse_time
@@ -687,3 +697,60 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts:
     conflicts["playlists"] = {}
 
     return conflicts
+
+
+class ProgramEntry(TypedDict):
+    id: str
+    start: datetime
+    end: datetime
+    show: Show
+    timeslot: TimeSlot | None
+
+
+def generate_program_entries(
+    queryset: QuerySet[TimeSlot],
+    *,
+    start: datetime | None,
+    end: datetime | None,
+    include_virtual: bool,
+) -> Iterator[ProgramEntry]:
+    """Gets list of timerange entries between the given `timerange_start` and `timerange_end`.
+
+    Include virtual timerange entries if requested."""
+
+    def create_entry(start: datetime, end: datetime, show: Show, timeslot: TimeSlot | None = None):
+        return ProgramEntry(
+            id=f"{start.isoformat()}...{end.isoformat()}",
+            start=start,
+            end=end,
+            timeslot=timeslot,
+            show=show,
+        )
+
+    def create_timeslot_entry(timeslot: TimeSlot):
+        return create_entry(timeslot.start, timeslot.end, timeslot.schedule.show, timeslot)
+
+    if start is None:
+        start = timezone.now()
+    if end is None:
+        end = start + timedelta(days=7)
+    queryset = queryset.filter(start__gte=start, start__lt=end)
+
+    if not include_virtual:
+        yield from (create_timeslot_entry(timeslot) for timeslot in queryset)
+        return
+
+    radio_settings: RadioSettings | None = RadioSettings.objects.first()
+    fallback_show = radio_settings.fallback_show if radio_settings is not None else None
+    if fallback_show is None:
+        raise ValueError("Radio settings must set fallback show if include_virtual is True.")
+
+    entry_start = start
+    timeslot: TimeSlot
+    for timeslot in queryset:
+        if timeslot.start > entry_start:
+            yield create_entry(entry_start, timeslot.start, fallback_show)
+        yield create_timeslot_entry(timeslot)
+        entry_start = timeslot.end
+    if entry_start != end:
+        yield create_entry(entry_start, end, fallback_show)
diff --git a/program/views.py b/program/views.py
index d4f3716524f19f4adbf8a1ee1fb7129149467fe6..aefd2f8491237941b89bc82676412d23ab975af1 100644
--- a/program/views.py
+++ b/program/views.py
@@ -66,6 +66,7 @@ from program.models import (
 from program.serializers import (
     CategorySerializer,
     ErrorSerializer,
+    ExtendedProgramEntrySerializer,
     FundingCategorySerializer,
     ImageRenderSerializer,
     ImageSerializer,
@@ -75,6 +76,7 @@ from program.serializers import (
     MusicFocusSerializer,
     NoteSerializer,
     ProfileSerializer,
+    ProgramEntrySerializer,
     RadioSettingsSerializer,
     RRuleSerializer,
     ScheduleConflictResponseSerializer,
@@ -94,6 +96,27 @@ from program.utils import get_values
 logger = logging.getLogger(__name__)
 
 
+@extend_schema_view(
+    list=extend_schema(
+        summary="List program for a specific date range.",
+    ),
+)
+class APIProgramViewSet(
+    mixins.ListModelMixin,
+    viewsets.GenericViewSet,
+):
+    filterset_class = filters.VirtualTimeslotFilterSet
+    queryset = TimeSlot.objects.all()
+
+    def get_serializer_class(self):
+        serializer_format = self.request.query_params.get("serializer_format", "simple")
+        if serializer_format == "simple":
+            return ProgramEntrySerializer
+        elif serializer_format == "extended":
+            return ExtendedProgramEntrySerializer
+        raise ValueError("Unknown serializer format")
+
+
 @extend_schema_view(
     create=extend_schema(summary="Create a new user."),
     retrieve=extend_schema(
diff --git a/steering/urls.py b/steering/urls.py
index 46cca2d469e8b44f2a6d8e9ce103a2d279ce6522..855667b68d4bfbffbf805892389b8517d6d81707 100644
--- a/steering/urls.py
+++ b/steering/urls.py
@@ -35,6 +35,7 @@ from program.views import (
     APIMusicFocusViewSet,
     APINoteViewSet,
     APIProfileViewSet,
+    APIProgramViewSet,
     APIRadioSettingsViewSet,
     APIRRuleViewSet,
     APIScheduleViewSet,
@@ -65,6 +66,7 @@ router.register(r"link-types", APILinkTypeViewSet, basename="link-type")
 router.register(r"rrules", APIRRuleViewSet, basename="rrule")
 router.register(r"images", APIImageViewSet, basename="image")
 router.register(r"settings", APIRadioSettingsViewSet, basename="settings")
+router.register(r"program", APIProgramViewSet, basename="program")
 
 urlpatterns = [
     path("openid/", include("oidc_provider.urls", namespace="oidc_provider")),