diff --git a/android/app/src/main/assets/phone/companion.html b/android/app/src/main/assets/phone/companion.html
index c8c78d4..da02b86 100644
--- a/android/app/src/main/assets/phone/companion.html
+++ b/android/app/src/main/assets/phone/companion.html
@@ -293,7 +293,9 @@ function create() {
// Shaped: the Director's layout, to change. Tap a show to move it to the next slot; the star marks a core show.
if (d.style === 'Shaped') {
const sh = c.shape;
- body += !sh ? '
The Director is laying it out…
' : `
The Director's layout
` + (sh.week ? `
${esc(sh.week)}
` : '') +
+ if (sh && !Object.keys(d.slots).length) body += `
The Director's layout
Your picks are all one kind, so they play all day rather than being split into daytime, evening and late. ★ Core shows still come round more often.
` : c.shape === null ? '' : '') + clashCard() + note;
next = ``;
}
return top + body + `
${next}
`;
@@ -353,10 +356,13 @@ async function browseShows() {
}
async function shapeShows() {
const c = state.create, d = c.draft;
- try { const s = await api('/api/shape', {ids: d.shows.map(x => x.id)}); c.shape = s;
- // The Director's layout fills in what you haven't set yourself.
- d.shows.forEach(x => { if (!c.moved?.[x.id]) d.slots[x.id] = s.slots[x.id] || 'Evening'; });
- if (!c.coreSet) d.core = s.core.slice(); }
+ try { const s = await api('/api/shape', {ids: d.shows.map(x => x.id), hours: d.hours}); c.shape = s; c.week = s.week;
+ if (d.style === 'Shaped') {
+ // The Director's layout fills in what you haven't set yourself. All one kind: it plays all day instead of being split.
+ if (s.mixed && !Object.keys(c.moved || {}).length) d.slots = {};
+ else d.shows.forEach(x => { if (!c.moved?.[x.id]) d.slots[x.id] = s.slots[x.id] || 'Evening'; });
+ if (!c.coreSet) d.core = s.core.slice();
+ } }
catch (e) { c.note = e.message; }
render();
}
@@ -472,7 +478,7 @@ document.addEventListener('click', e => {
if (d.cnext) { const c = state.create;
if (c.step === 0) { c.draft.name = ($('#cname')?.value || '').trim(); if (!c.draft.name) { toast('Give it a name first'); return; } }
if (c.step === 1 && !c.draft.shows.length) return;
- c.step++; c.note = ''; render(); scrollTo(0, 0); if (c.step === 2 && c.draft.style === 'Shaped') shapeShows(); if (c.step === 4) checkClashes(); return; }
+ c.step++; c.note = ''; render(); scrollTo(0, 0); if (c.step === 2 && c.draft.style === 'Shaped') shapeShows(); if (c.step === 4) { checkClashes(); shapeShows(); } return; }
if (d.csuggest) { suggestShows(); return; }
if (d.ctoggle) { const dr = state.create.draft, id = d.ctoggle; if ($('#cdesc')) dr.description = $('#cdesc').value.trim();
if (dr.shows.some(s => s.id === id)) dr.shows = dr.shows.filter(s => s.id !== id);
diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt
index be95961..b1dd731 100644
--- a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt
+++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt
@@ -187,6 +187,12 @@ internal class CatalogueStore(context: Context, private val file: File) : Catalo
buildList { while (c.moveToNext()) add(ShapeRow(c.getString(0), c.getString(1), c.getString(2), if (c.isNull(3)) null else c.getInt(3), c.getString(4), c.getString(5).split('|').filter { it.isNotBlank() }.toSet())) }
}
}
+ /** Films' running times by item, for Smart setup's hours estimate. Read only. */
+ @Synchronized fun runTimes(ids: Collection): Map = ids.distinct().chunked(400).flatMap { chunk ->
+ readableDatabase.rawQuery("SELECT id, runtime_ms FROM items WHERE runtime_ms IS NOT NULL AND id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c ->
+ buildList { while (c.moveToNext()) add(c.getString(0) to c.getLong(1)) }
+ }
+ }.toMap()
/** What Smart setup reads of chosen titles: kind, rating, genres and year. Read only. */
@Synchronized fun shapeRows(ids: Collection): List = ids.distinct().chunked(400).flatMap { chunk ->
readableDatabase.rawQuery("SELECT id, type, title, year, rating, genres FROM items WHERE id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c ->
diff --git a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt
index a40a6d3..d27feaf 100644
--- a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt
+++ b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt
@@ -50,7 +50,9 @@ internal class ChannelMaker(
val facets: suspend () -> Pair, List> = { emptyList() to emptyList() },
val shapeOf: suspend (List) -> List = { emptyList() },
/** For each title, the other channels already showing it (Paul, 7 November 2026: "check conflicts with the rest of the channels"). */
- val carriedBy: (picks: Map, number: Int?) -> Map> = { _, _ -> emptyMap() })
+ val carriedBy: (picks: Map, number: Int?) -> Map> = { _, _ -> emptyMap() },
+ /** How long the picks run all told, in milliseconds, or null when the library can't say. */
+ val runTime: suspend (List) -> Long? = { null })
internal enum class CreateStep(val title: String) { Start("Create a channel"), Name("Name and number"), Shows("Shows and films"), Style("How it plays"), Hours("When it's on") }
diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
index a4e92e1..2c56226 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
@@ -544,6 +544,11 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
browse = { genre, studio, ratings, type -> withContext(Dispatchers.IO) { catalogue.browseRows(genre, studio, ratings, type) } },
facets = { withContext(Dispatchers.IO) { catalogue.facets() } },
shapeOf = { ids -> withContext(Dispatchers.IO) { catalogue.shapeRows(ids) } },
+ runTime = { rows -> withContext(Dispatchers.IO) {
+ val films = rows.filter { it.type == "Movie" }.let { f -> catalogue.runTimes(f.map { it.id }).values.sum() }
+ val series = rows.filter { it.type == "Series" }.take(40).map { active.seriesRunTime(it.id) }
+ if (series.any { it == null }) null else films + series.sumOf { it ?: 0L }
+ } },
carriedBy = { picks, number -> titlesCarriedBy(picks, scheduledChannels.values.filter { it.number != number }) },
create = { asked, shows ->
// Shaped on the TV, or from the phone without a layout: the Director lays it out from the catalogue.
diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt
index 1b005b4..95ef7e0 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt
@@ -211,6 +211,16 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
return "inspect cast=${people?.length() ?: 0} [$cast]"
}
+ /** Smart setup's hours estimate: a series' episodes and their running time all told, read as metadata only. Null if it can't be read. */
+ fun seriesRunTime(seriesId: String): Long? = runCatching {
+ require(EmbyClient.validId(seriesId))
+ val reply = transport.execute(EmbyRequest("GET", "/Shows/$seriesId/Episodes?UserId=$userId&StartIndex=0&Limit=3000&EnableImages=false&EnableUserData=false",
+ token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS))
+ EmbyClient.expectSuccess(reply.status, false)
+ val rows = EmbyClient.parse(reply.body).optJSONArray("Items") ?: return null
+ (0 until rows.length()).sumOf { (rows.optJSONObject(it)?.optLong("RunTimeTicks", 0) ?: 0L) / 10_000 }
+ }.getOrNull()
+
fun inspectSeries(title: String, failed: Set = emptySet()): List {
val match = searchSeries(transport, userId, ::currentToken, title).candidates.filter { seriesKey(it.title) == seriesKey(title) }
if (match.isEmpty()) return listOf("inspect \"$title\": no series of that name")
diff --git a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
index 43b42e3..4a40f1e 100644
--- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
+++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
@@ -321,8 +321,13 @@ internal object PhoneLink {
val rows = runBlocking { h.maker.shapeOf(ids) }
val shape = shapeChannel(rows)
val series = rows.count { it.type == "Series" }; val films = rows.count { it.type == "Movie" }
+ // The real running time when the library can say, or the rough check from how many titles there are.
+ val hoursPerDay = runCatching { JSONObject(r.body).optJSONObject("hours")?.let { h -> ((h.getInt("to").let { if (it == 0) 24 else it } - h.getInt("from") + 24) % 24).takeIf { it > 0 } } }.getOrNull() ?: 24
+ val total = runCatching { runBlocking { h.maker.runTime(rows) } }.getOrNull()
json(out, 200, JSONObject().put("slots", JSONObject(shape.slots.mapValues { it.value.name })).put("core", JSONArray(shape.core.toList()))
- .put("week", weekCheck(series, films) ?: JSONObject.NULL))
+ .put("mixed", shape.slots.isEmpty() && rows.size >= 3)
+ .put("week", total?.takeIf { it > 0 }?.let { weekLine(it, hoursPerDay) } ?: weekCheck(series, films) ?: JSONObject.NULL)
+ .put("hours", total?.let { it / 3_600_000.0 } ?: JSONObject.NULL))
}
"POST /api/clashes" -> {
val o = runCatching { JSONObject(r.body) }.getOrNull() ?: return json(out, 400, JSONObject())
diff --git a/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt b/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt
index 4989d0d..cd5ef5e 100644
--- a/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt
+++ b/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt
@@ -31,14 +31,34 @@ internal fun slotFor(row: ShapeRow): Slot = when {
else -> Slot.Evening
}
-/** The whole layout: every title's slot, and up to three core titles, the newest of the evening's (or of all, with none there). */
+/**
+ * The whole layout: every title's slot, and up to three core titles, the newest of the evening's (or of all, with none there).
+ * The slots are a starting suggestion, not a rule (Paul, 7 November 2026): a channel that's nearly all one kind, an
+ * all-horror channel or a kids' one, isn't split up, and everything plays all day.
+ */
internal fun shapeChannel(rows: List): Shape {
- val slots = rows.associate { it.id to slotFor(it) }
+ val suggested = rows.associate { it.id to slotFor(it) }
+ val largest = suggested.values.groupingBy { it }.eachCount().maxOfOrNull { it.value } ?: 0
+ val slots = if (rows.size >= 3 && largest >= rows.size * .75) emptyMap() else suggested
val evening = rows.filter { slots[it.id] == Slot.Evening }.ifEmpty { rows }
return Shape(slots, evening.sortedByDescending { it.year ?: 0 }.take(3).map { it.id }.toSet())
}
-/** How the week looks: a gentle warning when the picks are few enough that episodes would repeat within days. */
+/**
+ * How the week looks, from the picks' real running time when it's known (Paul, 7 November 2026: "your selection provides
+ * roughly 42 hours"): how long before anything repeats, and a gentle warning when that's under a week of [hoursPerDay].
+ */
+internal fun weekLine(totalMs: Long, hoursPerDay: Int = 24): String {
+ val days = totalMs / 3_600_000.0 / hoursPerDay
+ val hours = hoursLine(totalMs)
+ return when {
+ days >= 7 -> "Your picks give $hours of programmes: more than a week before anything repeats."
+ days >= 2 -> "Your picks give $hours of programmes, so repeats start after about ${days.toInt()} days. A few more would fill the week."
+ else -> "Your picks give $hours of programmes, so you'd be watching repeats ${if (days < 1) "later today" else "by tomorrow"}. A few more would fill the week."
+ }
+}
+
+/** How the week looks without running times: a gentle warning when the picks are few enough that episodes would repeat within days. */
internal fun weekCheck(series: Int, films: Int): String? = when {
series + films == 0 -> null
series == 0 && films < 21 -> "$films films is under three a day, so they'd repeat within the week. Add a few more, or keep it as it is."
@@ -46,9 +66,24 @@ internal fun weekCheck(series: Int, films: Int): String? = when {
else -> null
}
-/** A layout as the planner reads it: each slot's titles for its hours, and the core titles weighted up. */
-internal fun shapedParts(slots: Map): List = Slot.entries.mapNotNull { s ->
- slots.filterValues { it == s }.keys.toList().takeIf { it.isNotEmpty() }?.let { DayPart(s.fromHour, s.toHour, it) }
+/**
+ * A layout as the planner reads it: each slot's titles for its hours. An empty slot shares the daytime and evening titles,
+ * never the late ones, so a late title stays late (Paul, 7 November 2026: the creator's choices and the profile's rules win).
+ * With nothing laid out, everything plays all day.
+ */
+internal fun shapedParts(slots: Map): List {
+ if (slots.isEmpty()) return emptyList()
+ val bySlot = Slot.entries.associateWith { s -> slots.filterValues { it == s }.keys.toList() }
+ val gentle = bySlot.getValue(Slot.Daytime) + bySlot.getValue(Slot.Evening)
+ return Slot.entries.mapNotNull { s ->
+ val titles = bySlot.getValue(s).ifEmpty { if (s == Slot.Late) gentle + bySlot.getValue(Slot.Late) else gentle }
+ titles.takeIf { it.isNotEmpty() }?.let { DayPart(s.fromHour, s.toHour, it) }
+ }
+}
+
+/** "About 42 hours", for the week check: how long the picks run before anything repeats. */
+internal fun hoursLine(totalMs: Long): String = (totalMs / 3_600_000.0).let { h ->
+ when { h < 1 -> "under an hour"; h < 10 -> "about %.0f hours".format(h).replace("about 1 hours", "about an hour"); else -> "about ${(h / 5).toInt() * 5} hours" }
}
internal fun shapedWeights(core: Set): Map = core.associate { seriesKey(it) to TIER_CORE }
diff --git a/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt b/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt
index 1ac92a9..c9fd5e2 100644
--- a/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt
+++ b/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt
@@ -49,4 +49,24 @@ class SmartSetupTest {
assertEquals(setOf("bcs", "aq"), carried.keys) // another copy of the show still counts
assertTrue(carried.getValue("bcs").single().startsWith("109 "))
}
+
+ @Test fun slotsAreASuggestionLateTitlesStayLateAndOneKindPlaysAllDay() {
+ // An all-horror channel isn't pushed into the small hours, and a kids' one isn't split.
+ assertTrue(shapeChannel(listOf(row("A", "Movie", "R", "Horror"), row("B", "Movie", "R", "Horror"), row("C", "Movie", "R", "Horror"), row("D", "Movie", "PG-13", "Horror"))).slots.isEmpty())
+ assertTrue(shapedParts(emptyMap()).isEmpty())
+ // No evening shows: the evening shares the daytime's, never the late one's.
+ val parts = shapedParts(mapOf("Bluey" to Slot.Daytime, "Line of Duty" to Slot.Late))
+ assertEquals(listOf("Bluey"), parts.first { it.fromHour == 17 }.titles)
+ assertEquals(listOf("Line of Duty"), parts.first { it.fromHour == 22 }.titles)
+ // No late shows: the gentle ones carry on through the night.
+ assertEquals(listOf("Bluey", "Doctor Who"), shapedParts(mapOf("Bluey" to Slot.Daytime, "Doctor Who" to Slot.Evening)).first { it.fromHour == 22 }.titles)
+ }
+
+ @Test fun theHoursLineSaysHowLongBeforeAnythingRepeats() {
+ val hour = 3_600_000L
+ assertTrue(weekLine(42 * hour).contains("about 40 hours")); assertTrue(weekLine(42 * hour).contains("about 1 days").not())
+ assertTrue(weekLine(12 * hour).contains("later today"))
+ assertTrue(weekLine(200 * hour).contains("more than a week"))
+ assertTrue(weekLine(42 * hour, hoursPerDay = 6).contains("more than a week")) // an evenings-only channel goes further
+ }
}