0.108.3: Black Mirror on Paradox after 21:00; After Dark anthology strand; linked episodes; Tales from the Crypt off Orbit 2 (built, not installed)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 08:14:42 +00:00
committed by Claude
co-authored by Claude Opus 5.5
parent 80d315481b
commit a57d0dfbe1
7 changed files with 197 additions and 15 deletions
@@ -36,7 +36,7 @@ private val Orbit2Panel = listOf("QI", "Have I Got News for You", "Would I Lie t
/** Retro Action, with Orbit 2's cult guests as late surprises. Paradox is their preferred home. */
private val Orbit2RetroAction = listOf("Knight Rider", "The A-Team", "Airwolf", "MacGyver",
"Street Hawk", "The Fall Guy", "Magnum, P.I.", "Miami Vice", "CHiPs", "The Dukes of Hazzard", "Blue Thunder",
"Quantum Leap", "Tales from the Crypt", "The Twilight Zone", "The Outer Limits", "Sliders", "Twin Peaks")
"Quantum Leap", "The Twilight Zone", "The Outer Limits", "Sliders", "Twin Peaks") // Tales from the Crypt moved to After Dark, 4 November 2026
private val Orbit2Spooky = listOf("Goosebumps", "Are You Afraid of the Dark?", "Eerie, Indiana")
internal val Orbit2Shows get() = (Orbit2Daytime + Orbit2Teatime + Orbit2GameShows + Orbit2Panel + Orbit2RetroAction + Orbit2Spooky).distinct()
internal val Orbit2DayParts get() = listOf(
@@ -338,6 +338,16 @@ internal val AfterDarkEvents get() = listOf(DayPart(21, 1, BMovieThrowbacks, day
DayPart(21, 1, listOf(LAST_DRIVE_IN), days = setOf(DayOfWeek.SATURDAY), onTime = true, key = "afterdark.drivein"),
DayPart(21, 1, CultClassics, days = setOf(DayOfWeek.SUNDAY), onTime = true, key = "afterdark.cult"))
/**
* After Dark's late strand of anthology (approved by Paul on 4 November 2026; docs/anthology-awareness-investigation.md): after
* the night's film, at 01:00 on Monday to Thursday nights, two Tales from the Crypt on Monday and Wednesday and three Love,
* Death & Robots shorts on Tuesday and Thursday, each in a fixed mixed order with linked episodes kept together.
*/
internal val AfterDarkAnthologies get() = listOf("Tales from the Crypt", "Love, Death & Robots")
internal val AfterDarkStrand get() = listOf(
DayPart(1, 2, listOf("Tales from the Crypt"), days = setOf(DayOfWeek.TUESDAY, DayOfWeek.THURSDAY), onTime = true, run = 2, shuffled = true),
DayPart(1, 2, listOf("Love, Death & Robots"), days = setOf(DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY), onTime = true, run = 3, shuffled = true))
/** Orbit Junior: British children's TV for about 5 to 11, with the classics at breakfast. Tots is the preferred home of modern preschool. */
private val JuniorBreakfast = listOf("Thomas the Tank Engine & Friends", "Thomas & Friends", "Postman Pat", "Brum", "Rosie and Jim", "Pingu", "Pingu in the City", "Button Moon",
"Tots TV", "Bagpuss", "Camberwick Green", "Trumpton", "The Clangers", "Tweenies", "Rainbow", "The Wombles", "Noddy", "Noddy's Toyland Adventures", "Balamory", "Fimbles",
@@ -407,7 +417,7 @@ internal val Orbit1Tiers: Map<String, Double> get() =
/** Orbit 2's tiers, from docs/channels/orbit-2.md (approved 4 November 2026): core 3, surprises 1, everything else 2. */
internal val Orbit2Tiers: Map<String, Double> get() =
listOf("Airwolf", "Countdown", "Gladiators", "Have I Got News for You", "Knight Rider", "MacGyver", "Mastermind", "Pointless", "QI", "Robot Wars", "Takeshi's Castle", "The A-Team", "The Crystal Maze", "University Challenge", "Would I Lie to You?").associate { seriesKey(it) to TIER_CORE } +
listOf("Are You Afraid of the Dark?", "Eerie, Indiana", "Goosebumps", "Quantum Leap", "Tales from the Crypt").associate { seriesKey(it) to TIER_SURPRISE }
listOf("Are You Afraid of the Dark?", "Eerie, Indiana", "Goosebumps", "Quantum Leap").associate { seriesKey(it) to TIER_SURPRISE }
/** Gold's tiers, from docs/channels/orbit-gold.md (approved 4 November 2026): core 3, surprises 1, everything else 2. */
internal val GoldTiers: Map<String, Double> get() =
listOf("Blackadder", "Dad's Army", "Father Ted", "Fawlty Towers", "Keeping Up Appearances", "One Foot in the Grave", "Only Fools and Horses", "Open All Hours", "Porridge", "The Vicar of Dibley").associate { seriesKey(it) to TIER_CORE } +
@@ -27,7 +27,9 @@ internal data class DayPart(val fromHour: Int, val toHour: Int, val titles: List
/** Set to true for a block only on the channel's premiere nights, false for one only on its other nights. Null is every night. */
val premiereNights: Boolean? = null,
/** An appointment's own allowance for starting late, instead of [ON_TIME_GRACE_MS]. */
val graceMs: Long? = null) {
val graceMs: Long? = null,
/** On a film channel, a series event's episodes a night (a strand), and whether they go round a mixed deck. */
val run: Int = 1, val shuffled: Boolean = false) {
fun covers(hour: Int) = if (fromHour <= toHour) hour in fromHour until toHour else hour >= fromHour || hour < toHour
/** An event that runs past midnight belongs to the night it started, so its small hours count from the day before. */
fun covers(hour: Int, day: java.time.DayOfWeek) = covers(hour) && (days.isEmpty() || (if (fromHour > toHour && hour < toHour) day.minus(1) else day) in days)
@@ -92,8 +94,11 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se
private val ParadoxStandalones = listOf("Doctor Who (2005)", "Doctor Who (2023)", "Star Trek: The Next Generation", "The X-Files", "The Twilight Zone", "Stargate SG-1", "Sliders", "Warehouse 13", "Eureka")
private val ParadoxPrimeTime = listOf("Buffy the Vampire Slayer", "Angel", "Supernatural", "Fringe")
private val ParadoxLate = listOf("Twin Peaks", "The Outer Limits", "The Outer Limits (1995)", "Torchwood", "Farscape", "The X-Files")
private val ParadoxShows get() = (ParadoxStandalones + ParadoxPrimeTime + ParadoxLate).distinct()
private val ParadoxDayParts get() = listOf(DayPart(6, 18, ParadoxStandalones), DayPart(18, 23, ParadoxPrimeTime), DayPart(23, 6, ParadoxLate))
/** Adult anthology for the late evening only (approved by Paul on 4 November 2026): standalone stories, in a mixed order. */
private val ParadoxAdult = listOf("Black Mirror")
private val ParadoxShows get() = (ParadoxStandalones + ParadoxPrimeTime + ParadoxLate + ParadoxAdult).distinct()
private val ParadoxDayParts get() = listOf(DayPart(6, 18, ParadoxStandalones), DayPart(18, 21, ParadoxPrimeTime), DayPart(21, 23, ParadoxPrimeTime + ParadoxAdult),
DayPart(23, 6, ParadoxLate + ParadoxAdult))
private val NotCultCinema = setOf("Family", "Animation", "Documentary", "Music")
@@ -170,9 +175,9 @@ private val AfterDarkFilms = listOf("The Thing (1982)", "The Evil Dead (1983)",
"Army of Darkness", "House (1977)", "The Holy Mountain", "Repo! The Genetic Opera")
internal val SmartChannels = listOf(
ChannelProgramming(AFTER_DARK_NUMBER, "After Dark", AfterDarkFilms, seriesCount = AfterDarkFilms.size, filmSeries = listOf(LAST_DRIVE_IN), filmRestMs = 7 * 24 * 60 * 60_000L, adaptiveFilmRest = true,
ChannelProgramming(AFTER_DARK_NUMBER, "After Dark", AfterDarkFilms, seriesCount = AfterDarkFilms.size, filmSeries = listOf(LAST_DRIVE_IN) + AfterDarkAnthologies, filmRestMs = 7 * 24 * 60 * 60_000L, adaptiveFilmRest = true,
withdrawn = AfterDarkWithdrawn,
dayParts = AfterDarkEvents + listOf(DayPart(21, 5), DayPart(5, 21, listOf("The 'Burbs", "'Til Death Do Us Part (2017)", "Tremors (1990)", "The Lost Boys (1987)", "Fright Night (1985)", "Poltergeist (1982)", "Scream (1996)",
dayParts = AfterDarkEvents + AfterDarkStrand + listOf(DayPart(21, 5), DayPart(5, 21, listOf("The 'Burbs", "'Til Death Do Us Part (2017)", "Tremors (1990)", "The Lost Boys (1987)", "Fright Night (1985)", "Poltergeist (1982)", "Scream (1996)",
"Escape from New York", "They Live", "Repo Man", "Donnie Darko", "From Dusk Till Dawn", "Creepshow", "Near Dark", "Halloween (1978)", "The Fog (1980)", "Blue Velvet",
"The Wicker Man (1973)", "An American Werewolf in London", "Prince of Darkness") + AfterDarkDaytimeCrossover, group = "day")),
suggestionRules = listOf(
@@ -192,7 +197,7 @@ internal val SmartChannels = listOf(
ChannelProgramming(201, "Terra", TerraShows, seriesCount = TerraShows.size, finishTogether = true, exactTitles = true, dayParts = TerraDayParts),
ChannelProgramming(TOTS_NUMBER, "Tots", TotsShows, seriesCount = 40, finishTogether = true, exactTitles = true, dayParts = TotsDayParts, shuffleEpisodes = true,
tierWeights = TotsTiers, catalogueScaleHours = CATALOGUE_SCALE_HOURS),
ChannelProgramming(106, "Paradox", ParadoxShows, seriesCount = 20, finishTogether = true, exactTitles = true, dayParts = ParadoxDayParts, shuffleTitles = ParadoxStandalones.toSet(),
ChannelProgramming(106, "Paradox", ParadoxShows, seriesCount = 20, finishTogether = true, exactTitles = true, dayParts = ParadoxDayParts, shuffleTitles = (ParadoxStandalones + ParadoxAdult).toSet(),
tierWeights = ParadoxTiers, storyGrouping = true)) +
ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = if (it.films) it.searches.size else 60, finishTogether = !it.films, exactTitles = true,
filmRestMs = if (it.films) 7 * 24 * 60 * 60_000L else null,
@@ -447,13 +452,23 @@ internal fun extendFilmSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor,
// An event naming only a series plays its next whole episode, once a night. Everywhere else is films only.
val series = available.singleOrNull()?.takeIf { !it.isFilm && block.startsWith("event ") }
if (series != null && shown[series.id]?.let { start - it < SERIES_NIGHT_MS } != true) {
val episode = nextEpisode(series, marks[series.id])
entries += ScheduleEntry(series.id, episode.mark, ScheduledMedia(scheduledItem(series.title, episode), episode.sourceId, episode.durationMs), start,
"$block; next episode in order, played whole", part?.key.orEmpty())
marks[series.id] = episode.mark
// A strand ([DayPart.run]) plays several episodes back to back; a shuffled one goes round a fixed mixed deck in
// which linked episodes and stories stay together and in order ([storiesOf]).
val deck = if (part?.shuffled == true) shuffledStories(series, storiesOf(series)) else null
var mark = marks[series.id]
val stories = storiesOf(series)
var played = 0
while (played < (part?.run ?: 1) || (mark != null && stories.of(series.episodes.first { it.mark == mark })?.let { story -> story.parts.last().mark != mark } == true)) {
val episode = deck?.let { d -> d[(d.indexOfFirst { it.mark == mark } + 1) % d.size] } ?: nextEpisode(series, mark)
entries += ScheduleEntry(series.id, episode.mark, ScheduledMedia(scheduledItem(series.title, episode), episode.sourceId, episode.durationMs), start,
"$block; " + (if (deck != null) "next in its mixed order" else "next episode in order") + ", played whole", part?.key.orEmpty())
mark = episode.mark
start += episode.durationMs
played++
}
marks[series.id] = mark!!
shown[series.id] = start
last = series.id
start += episode.durationMs
continue
}
val films = available.filter { it.isFilm }.ifEmpty { pool.filter { it.isFilm } }.ifEmpty { available }
@@ -31,8 +31,23 @@ internal fun partNumber(title: String): Int? = PartNumber.find(title)?.let { mat
number.toIntOrNull() ?: PartWords[number.lowercase()]
}
/**
* Linked episodes the numbering doesn't show, found by the anthology cast check (4 November 2026): a sequel that shares its
* original's title and three or more of its leads. Each is kept with its original, in order, as a story is.
*/
internal val LinkedEpisodes: Map<String, List<List<EpisodeMark>>> get() = mapOf(
seriesKey("Black Mirror") to listOf(listOf(EpisodeMark(4, 1), EpisodeMark(7, 6))), // USS Callister, then Into Infinity
seriesKey("Love, Death & Robots") to listOf(listOf(EpisodeMark(1, 2), EpisodeMark(3, 1)))) // Three Robots, then Exit Strategies
internal fun storiesOf(show: PoolSeries): ShowStories {
if (show.isFilm) return ShowStories(emptyList())
val linked = LinkedEpisodes[seriesKey(show.title)].orEmpty().mapNotNull { marks ->
marks.map { mark -> show.episodes.firstOrNull { it.mark == mark } ?: return@mapNotNull null }.let(::Story) }
if (linked.isNotEmpty()) return ShowStories(numberedStories(show) + linked)
return ShowStories(numberedStories(show))
}
private fun numberedStories(show: PoolSeries): List<Story> {
val episodes = show.episodes.sortedBy { it.mark }
val lastOfSeason = episodes.groupBy { it.mark.season }.mapValues { (_, it) -> it.maxOf { e -> e.mark.episode } }
fun follows(before: PoolEpisode, after: PoolEpisode) = (after.mark.season == before.mark.season && after.mark.episode == before.mark.episode + 1) ||
@@ -46,7 +61,7 @@ internal fun storiesOf(show: PoolSeries): ShowStories {
if (run.size in 2..MAX_STORY_PARTS) stories += Story(run)
i += run.size
}
return ShowStories(stories)
return stories
}
/**
@@ -0,0 +1,72 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
/** Anthologies (approved by Paul on 4 November 2026): standalone stories shuffle, linked episodes stay together and in order. */
class AnthologyTest {
private val london = ZoneId.of("Europe/London")
private val minute = 60_000L
private fun at(d: Int, h: Int) = ZonedDateTime.of(2026, 11, d, h, 0, 0, 0, london).toInstant().toEpochMilli()
private fun show(id: String, title: String, marks: List<Pair<Int, Int>>, minutes: Long) =
PoolSeries(id, title, marks.map { (s, e) -> PoolEpisode("$id-$s-$e", "src", EpisodeMark(s, e), "E$s.$e", minutes * minute) })
private fun film(n: Int) = "f$n".let { id -> PoolSeries(id, "Film $n", listOf(PoolEpisode(id, "s$id", EpisodeMark(1, 1), "Film $n", (90L + n % 30) * minute))) }
private val mirror = show("bm", "Black Mirror", (1..7).flatMap { s -> (1..6).map { s to it } }, 60)
private val robots = show("ldr", "Love, Death & Robots", (1..4).flatMap { s -> (1..9).map { s to it } }, 14)
private val crypt = show("tftc", "Tales from the Crypt", (1..7).flatMap { s -> (1..13).map { s to it } }, 26)
@Test fun linkedEpisodesAreAStoryInOrder() {
val callister = storiesOf(mirror).of(mirror.episodes.first { it.mark == EpisodeMark(4, 1) })!!
assertEquals(listOf(EpisodeMark(4, 1), EpisodeMark(7, 6)), callister.parts.map { it.mark })
val deck = shuffledStories(mirror, storiesOf(mirror))
val i = deck.indexOfFirst { it.mark == EpisodeMark(4, 1) }
assertEquals(EpisodeMark(7, 6), deck[i + 1].mark)
assertEquals(mirror.episodes.size, deck.size)
// Anything else has no links.
assertTrue(storiesOf(crypt).stories.isEmpty())
}
@Test fun afterDarksStrandPlaysItsMixedRunAfterTheFilm() {
val pool = (1..40).map(::film) + crypt + robots
val parts = AfterDarkStrand + listOf(DayPart(21, 5), DayPart(5, 21))
val plan = extendFilmSchedule(pool, ScheduleCursor(), at(9, 12), at(16, 12), 2 * 24 * 60 * minute, parts, london)
val strand = plan.entries.filter { !it.media.item.title.startsWith("Film") }
// Monday and Wednesday nights: two Crypts; Tuesday and Thursday nights: three shorts; nothing at the weekend.
val byNight = strand.groupBy { Instant.ofEpochMilli(it.startMs).atZone(london).toLocalDate() }
assertEquals(setOf(java.time.DayOfWeek.TUESDAY, java.time.DayOfWeek.WEDNESDAY, java.time.DayOfWeek.THURSDAY, java.time.DayOfWeek.FRIDAY), byNight.keys.map { it.dayOfWeek }.toSet())
byNight.forEach { (day, entries) ->
val crypts = entries.count { it.seriesId == "tftc" }; val shorts = entries.count { it.seriesId == "ldr" }
if (day.dayOfWeek in setOf(java.time.DayOfWeek.TUESDAY, java.time.DayOfWeek.THURSDAY)) assertEquals(2, crypts) else assertTrue(shorts >= 3)
entries.zipWithNext { a, b -> assertEquals(a.endMs, b.startMs) }
assertTrue(Instant.ofEpochMilli(entries.first().startMs).atZone(london).hour in 1..1)
}
// A mixed order, not season order.
val crypts = strand.filter { it.seriesId == "tftc" }.map { it.mark }
assertNotEquals(crypts.sortedWith(compareBy({ it.season }, { it.episode })), crypts)
plan.entries.zipWithNext { a, b -> assertEquals(a.endMs, b.startMs) }
}
@Test fun threeRobotsNeverAirsExitStrategiesFirst() {
val pool = (1..40).map(::film) + robots
val parts = AfterDarkStrand + listOf(DayPart(21, 5), DayPart(5, 21))
val plan = extendFilmSchedule(pool, ScheduleCursor(), at(2, 12), at(30, 12) + 120L * 24 * 60 * minute, 2 * 24 * 60 * minute, parts, london)
val shorts = plan.entries.filter { it.seriesId == "ldr" }
val first = shorts.indexOfFirst { it.mark == EpisodeMark(1, 2) }; val sequel = shorts.indexOfFirst { it.mark == EpisodeMark(3, 1) }
assertTrue(first >= 0 && sequel == first + 1)
}
@Test fun theChannelsAreAsApproved() {
val paradox = SmartChannels.first { it.number == 106 }
assertTrue("Black Mirror" in paradox.searches)
assertTrue(paradox.mixes(mirror))
// Only from 21:00: not in the 18:00 to 21:00 block or the day.
assertTrue(paradox.dayParts.filter { "Black Mirror" in it.titles }.all { it.fromHour >= 21 })
val afterDark = SmartChannels.first { it.number == AFTER_DARK_NUMBER }
assertTrue(afterDark.filmSeries.containsAll(listOf("Tales from the Crypt", "Love, Death & Robots")))
assertTrue("Tales from the Crypt" !in SmartChannels.first { it.number == 102 }.searches)
}
}
@@ -93,6 +93,6 @@ class PpvSundayTest {
assertTrue(ringX.signature.contains("#films:"))
assertTrue(ringX.signature.contains(PpvSunday.first()))
val afterDark = SmartChannels.first { it.number == AFTER_DARK_NUMBER }
assertTrue(afterDark.signature.contains("#series:$LAST_DRIVE_IN#"))
assertTrue(afterDark.signature.contains("#series:${(listOf(LAST_DRIVE_IN) + AfterDarkAnthologies).joinToString("|")}#"))
}
}
+40
View File
@@ -0,0 +1,40 @@
# 0.108.3: anthologies on Paradox and After Dark, built on the installed 0.108.2, NOT installed
Paul approved proposals 1 to 3 on 4 November 2026 at 22:01 (docs/anthology-awareness-investigation.md). This release is built on 0.108.2, the version on the box, so Advent and The Naughty List aren't in it. The same change is on main.
## What it does
**Black Mirror on Paradox (106), from 21:00 only.**
- **Blocks:** Paradox's 18:00 to 23:00 block is split into 18:00 to 21:00 (unchanged shows) and 21:00 to 23:00 (the same shows plus Black Mirror). Black Mirror also joins 23:00 to 06:00.
- **Order:** it's shuffled as standalones.
- **Copy:** the pool read keeps the copy with the most episodes, 7536213, which has 32 regular episodes.
- **Specials:** season 0 is never read, so Bandersnatch (interactive) and the White Christmas special are left out.
**After Dark (305) late strand.**
- **What and when:** Tales from the Crypt and Love, Death & Robots, read whole as After Dark's Joe Bob series is (`filmSeries`). New `AfterDarkStrand` appointments at 01:00:
- Monday and Wednesday nights: two Tales from the Crypt;
- Tuesday and Thursday nights: three Love, Death & Robots shorts.
- **How:** `DayPart.run` and `DayPart.shuffled` let a series event on a film channel play several episodes from a fixed mixed deck. The films before it finish in time, as for any appointment.
**Linked episodes** (`LinkedEpisodes` in StoryGroups.kt, from the cast check) are kept together and in order, as stories are:
- Black Mirror: USS Callister, then Into Infinity.
- Love, Death & Robots: Three Robots, then Exit Strategies.
**Tales from the Crypt leaves Orbit 2's** Retro Action list and tiers. Orbit 2's pool drops it at its next read, and its existing bookings stay as booked.
## Checks
- 486 unit tests run, 0 failures; lint has no errors. `AnthologyTest` adds 4 tests:
- linked episodes form a story and sit together in the deck;
- the strand's nights, counts, mixed order and on-time run;
- Three Robots never airs its sequel first;
- the channels are as approved.
- **Four-week replay** from the box's plans at 22:29 today (`ReplayFingerprintTest`). The pools were the box's own, with Black Mirror, Tales from the Crypt and Love, Death & Robots added from the on-box inspections, and Tales from the Crypt removed from Orbit 2.
- **Fingerprints:** every channel's is identical except Orbit 2, Paradox and After Dark. All committed bookings are kept (`kept=true`).
- **Paradox:** Black Mirror airs about 37 times in four weeks, all at 21:00 or later. USS Callister is followed directly by Into Infinity. The order is mixed. The X-Files has 123 → 115 airings, Supernatural 60 → 56 and Buffy 56 → 52.
- **After Dark:** 16 strand nights in the four weeks. Each starts between 01:02 and 01:17, after that night's film. It totals 16 Crypts and 24 shorts.
- **Orbit 2:** only small reshuffles once Tales from the Crypt is gone, of one or two programmes per show.
**Black Mirror's share on Paradox:** it runs at the normal tier, which gives it the 37 airings above. Making it a "surprise" tier would roughly halve that. That's Paul's call.
APK: `outputs/revision-0.108.3`, signed, versionCode 163. **Not installed.**
@@ -0,0 +1,30 @@
101 committed=af5c16550fe392e8 kept=true fourWeeks=c7b3ba6191dccdc4 programmes=2147
102 committed=922490b3996c41fb kept=true fourWeeks=b1a9013c24381155 programmes=1296
103 committed=0918ee2c03e716e8 kept=true fourWeeks=f2ac211e4a4b9072 programmes=1795
104 committed=6f6a2f491703e112 kept=true fourWeeks=d292d863a41e7bbc programmes=1710
105 committed=17abed5208c9d1ad kept=true fourWeeks=dbe25703104fda57 programmes=2525
106 committed=3efc0cedba402fca kept=true fourWeeks=9beec7545969be6f programmes=1124
107 committed=f4059d1aae164bde kept=true fourWeeks=0d72aeaa11f91b10 programmes=1000
201 committed=ca5a8865812f7909 kept=true fourWeeks=e58367ea15bdb46b programmes=949
202 committed=f047a503df58c243 kept=true fourWeeks=7c10bff8f112f0b8 programmes=1117
203 committed=43d85b99498c3647 kept=true fourWeeks=75f120d14806d8b2 programmes=1122
204 committed=75426228a60bd5db kept=true fourWeeks=275feb59680e538e programmes=1246
301 committed=a2be939a7727fbe4 kept=true fourWeeks=a3e54e71690c7b7e programmes=357
302 committed=42447fbb1928fe84 kept=true fourWeeks=9b1776c0ae9f350d programmes=436
303 committed=341c373541eb1f9a kept=true fourWeeks=519214275cc45da2 programmes=413
304 committed=36a9cc7637a97c62 kept=true fourWeeks=1e3ed510c5da5fa4 programmes=486
305 committed=bce04ffed4ae362a kept=true fourWeeks=16ffb5a3b5a63c2a programmes=533
306 committed=4769d0a01972d4da kept=true fourWeeks=6da09324c161095f programmes=486
307 committed=759fcec471f60b24 kept=true fourWeeks=a4b8d8aefdf199a0 programmes=438
308 committed=42036cefce120ef5 kept=true fourWeeks=4a9e31fdb6e1d23a programmes=510
309 committed=bea0bff60803200f kept=true fourWeeks=7c60691856057a56 programmes=491
401 committed=13f8347e5c01ec30 kept=true fourWeeks=ce30ecd9bc0832fb programmes=867
601 committed=3300a4019fd1b6aa kept=true fourWeeks=b84da138b7d5bd83 programmes=2174
602 committed=31b7055fe7d3b96e kept=true fourWeeks=f1c3f82f461e3719 programmes=2304
603 committed=23a6259ff689581f kept=true fourWeeks=c60e59f786858f46 programmes=2284
604 committed=b443664b922658f4 kept=true fourWeeks=e2f280386111cd4f programmes=3215
605 committed=82e0823ef1c7bbf2 kept=true fourWeeks=ca1fb1f642e4491a programmes=4457
611 committed=ad55af51bab2e926 kept=true fourWeeks=ff15635df75eb739 programmes=2701
612 committed=a9b6d7ea8d9511fe kept=true fourWeeks=fae95ada5f64df8e programmes=572
613 committed=df565f8d4f207103 kept=true fourWeeks=415e69170d79544f programmes=514
614 committed=e36973393d11a9ba kept=true fourWeeks=c9a50f8128ce6ee8 programmes=3795