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.
+
${d.shows.map(x => `
${esc(x.title)}
`).join('')}
`; + else body += !sh ? '
The Director is laying it out…
' : `
The Director's layout
` + SLOTS.map(([slot, hours]) => { const mine = d.shows.filter(x => (d.slots[x.id] || 'Evening') === slot); return `

${slot}${hours}

${mine.length ? mine.map(x => `
@@ -309,7 +311,8 @@ function create() { } else { body = `
Review
${esc(d.name)}
Yours${d.number ? ' · ' + d.number : ''} · ${STYLES.find(s => s[0] === d.style)[1]} · ${HOURS.find(([h]) => sameHours(h, d.hours))?.[1] || 'Set hours'}
- ${d.shows.slice(0, 8).map(s => `
${d.core.includes(s.id) ? '★ ' : ''}${esc(s.title)}${d.style === 'Shaped' ? ` · ${esc(d.slots[s.id] || 'Evening')}` : ''}
`).join('')}${d.shows.length > 8 ? `
and ${d.shows.length - 8} more
` : ''}
` + clashCard() + note; + ${d.shows.slice(0, 8).map(s => `
${d.core.includes(s.id) ? '★ ' : ''}${esc(s.title)}${d.style === 'Shaped' ? ` · ${esc(d.slots[s.id] || 'Evening')}` : ''}
`).join('')}${d.shows.length > 8 ? `
and ${d.shows.length - 8} more
` : ''}
` + + (c.week ? `
${esc(c.week)}
` : 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 + } }