diff --git a/program/filters.py b/program/filters.py
index af8421a919ffd1607b6c29dff3c70cec61654275..d442c2acc34d4669dcc5c90c4ad08bcf857b82a2 100644
--- a/program/filters.py
+++ b/program/filters.py
@@ -300,6 +300,13 @@ class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
 class VirtualTimeslotFilterSet(filters.FilterSet):
     start = filters.IsoDateTimeFilter(method="filter_noop")
     end = filters.IsoDateTimeFilter(method="filter_noop")
+    cut_at_range_boundaries = filters.BooleanFilter(
+        help_text=(
+            "If true guarantees that the first and last program entry match the requested range "
+            "even if these entries earlier or end later."
+        ),
+        method="filter_noop",
+    )
     include_virtual = filters.BooleanFilter(
         help_text="Include virtual timeslot entries (default: false).",
         method="filter_noop",
@@ -319,7 +326,8 @@ class VirtualTimeslotFilterSet(filters.FilterSet):
                 queryset,
                 start=filter_data["start"],
                 end=filter_data["end"],
-                include_virtual=filter_data["include_virtual"],
+                include_virtual=bool(filter_data["include_virtual"]),
+                cut_at_range_boundaries=bool(filter_data["cut_at_range_boundaries"]),
             )
         )
 
diff --git a/program/serializers.py b/program/serializers.py
index bbae2a63899d1d5f737b7ea5781927bc993baf73..cf89e77e9e937a96b256f4a78bcdffcea25c09c0 100644
--- a/program/serializers.py
+++ b/program/serializers.py
@@ -1243,7 +1243,7 @@ class RadioSettingsSerializer(serializers.ModelSerializer):
 
 
 class BasicProgramEntrySerializer(serializers.Serializer):
-    id = serializers.CharField()
+    id = serializers.UUIDField()
     start = serializers.DateTimeField()
     end = serializers.DateTimeField()
     timeslot_id = serializers.IntegerField(allow_null=True, source="timeslot.id")
diff --git a/program/services.py b/program/services.py
index 87693930e93d08256d2b2c79e48c0ca467ea01a9..df7565b51019f9dd6e02f0cb729a13b9f2fe3cb8 100644
--- a/program/services.py
+++ b/program/services.py
@@ -18,6 +18,8 @@
 #
 
 import copy
+import hashlib
+import uuid
 from collections.abc import Iterator
 from datetime import datetime, time, timedelta
 
@@ -700,32 +702,54 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts:
     return conflicts
 
 
+def get_fallback_show(raise_exceptions: bool = False):
+    radio_settings: RadioSettings | None = RadioSettings.objects.first()
+    fallback_show = radio_settings.fallback_show if radio_settings is not None else None
+    if raise_exceptions and fallback_show is None:
+        raise ConfigurationError(
+            "Radio settings must define a fallback show if include_virtual is True.",
+            code="no-fallback-show-defined",
+        )
+    return fallback_show
+
+
+def uuid_from_string(data: str):
+    hash = hashlib.md5()
+    hash.update(data.encode())
+    return uuid.UUID(hex=hash.hexdigest(), version=4)
+
+
 def generate_program_entries(
     queryset: QuerySet[TimeSlot],
     *,
     start: datetime | None,
     end: datetime | None,
     include_virtual: bool,
+    cut_at_range_boundaries: 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):
+    def create_entry(
+        starts_at: datetime, ends_at: datetime, show: Show, timeslot: TimeSlot | None = None
+    ):
+        entry_id = uuid_from_string(f"{starts_at.isoformat()}...{ends_at.isoformat()}")
+        if cut_at_range_boundaries:
+            starts_at = max(starts_at, start)
+            ends_at = min(ends_at, end)
         return ProgramEntry(
-            id=f"{start.isoformat()}...{end.isoformat()}",
-            start=start,
-            end=end,
+            id=entry_id,
+            start=starts_at,
+            end=ends_at,
             timeslot=timeslot,
             show=show,
         )
 
     def create_timeslot_entry(timeslot: TimeSlot):
         return create_entry(
-            # Ensure the program entry never starts before the requested start
-            # and never ends after the requested end.
-            max(timeslot.start, start),
-            min(timeslot.end, end),
+            timeslot.start,
+            timeslot.end,
             timeslot.schedule.show,
             timeslot,
         )
@@ -734,29 +758,40 @@ def generate_program_entries(
         start = timezone.now()
     if end is None:
         end = start + timedelta(days=1)
+
+    queryset = queryset.order_by("start")
     # Find all timeslots that
     #   * have started before the specified start value but end after it
     #   * or end after the specified end value but start before it
-    queryset = queryset.filter(end__gt=start, start__lt=end).order_by("start")
+    timeslots = queryset.filter(end__gt=start, start__lt=end)
 
     if not include_virtual:
-        yield from (create_timeslot_entry(timeslot) for timeslot in queryset)
+        yield from (create_timeslot_entry(timeslot) for timeslot in timeslots)
         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 ConfigurationError(
-            "Radio settings must define a fallback show if include_virtual is True.",
-            code="no-fallback-show-defined",
-        )
-
-    entry_start = start
+    # Program entries that are not based on scheduled timeslots are generated using the fallback
+    # show. We can only create these program entries if a fallback show has been specified.
+    fallback_show = get_fallback_show(raise_exceptions=True)
+
+    # Shift the range start/end to the closest scheduled timeslots around the specified range.
+    # This ensures that we generate stable ids for entries.
+    # We first check if the timeslots in our queryset might already start/end before/after the
+    # specified range, because we potentially include them according to the filter above.
+    first_ts_before_start = timeslots.first()
+    if not first_ts_before_start or first_ts_before_start.start > start:
+        first_ts_before_start = queryset.filter(end__lte=start).last()
+    first_ts_after_end = timeslots.last()
+    if not first_ts_after_end or first_ts_after_end.end < end:
+        first_ts_after_end = queryset.filter(start__gte=end).first()
+    range_start = first_ts_before_start.end if first_ts_before_start else start
+    range_end = first_ts_after_end.start if first_ts_after_end else end
+
+    entry_start = range_start
     timeslot: TimeSlot
-    for timeslot in queryset:
+    for timeslot in timeslots:
         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)
+    if entry_start < range_end:
+        yield create_entry(entry_start, range_end, fallback_show)
diff --git a/program/views.py b/program/views.py
index a03556f645a8bf84dc04fd5682b2550211b4a526..bfc700feb4abafa96f9edb2ad50b735ddca5e846 100644
--- a/program/views.py
+++ b/program/views.py
@@ -150,39 +150,39 @@ class APIProgramBasicViewSet(AbstractAPIProgramViewSet):
             OpenApiExample(
                 "Example entry",
                 value={
-                    "end": "2024-07-16T12:30:00-04:00",
-                    "episode": {"id": 11, "title": ""},
-                    "id": "2024-07-16T14:00:00+00:00...2024-07-16T16:30:00+00:00",
+                    "end": "2024-07-31T12:15:00-04:00",
+                    "episode": {"id": 6, "title": ""},
+                    "id": "44b26957-fa84-4704-89dd-308e26b00556",
                     "playlistId": None,
                     "schedule": {"defaultPlaylistId": None, "id": 1},
-                    "show": {"defaultPlaylistId": None, "id": 2, "name": "EINS"},
-                    "showId": 2,
-                    "start": "2024-07-16T10:00:00-04:00",
+                    "show": {"defaultPlaylistId": None, "id": 1, "name": "EINS"},
+                    "showId": 1,
+                    "start": "2024-07-31T11:00:00-04:00",
                     "timeslot": {
-                        "end": "2024-07-16T12:30:00-04:00",
-                        "id": 11,
+                        "end": "2024-07-31T12:15:00-04:00",
+                        "id": 6,
                         "memo": "",
-                        "noteId": 11,
+                        "noteId": 6,
                         "playlistId": None,
                         "repetitionOfId": None,
                         "scheduleId": 1,
-                        "showId": 2,
-                        "start": "2024-07-16T10:00:00-04:00",
+                        "showId": 1,
+                        "start": "2024-07-31T11:00:00-04:00",
                     },
-                    "timeslotId": 11,
+                    "timeslotId": 6,
                 },
             ),
             OpenApiExample(
                 "Example virtual entry",
                 value={
-                    "end": "2024-07-16T15:23:34.084852-04:00",
+                    "end": "2024-08-01T11:00:00-04:00",
                     "episode": None,
-                    "id": "2024-07-16T16:30:00+00:00...2024-07-16T19:23:34.084852+00:00",
+                    "id": "5e8a3075-b5d6-40c8-97d1-5ee11d8a090d",
                     "playlistId": None,
                     "schedule": None,
-                    "show": {"defaultPlaylistId": None, "id": 1, "name": "Musikpool"},
-                    "showId": 1,
-                    "start": "2024-07-16T12:30:00-04:00",
+                    "show": {"defaultPlaylistId": None, "id": 2, "name": "Musikpool"},
+                    "showId": 2,
+                    "start": "2024-07-31T12:15:00-04:00",
                     "timeslot": None,
                     "timeslotId": None,
                 },