Smart setup refinements (from release 0.108.83)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-07 08:24:56 +00:00
co-authored by Claude Opus 5.5
parent 59514dc66d
commit 7ca24d6703
8 changed files with 104 additions and 15 deletions
@@ -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 ? '<div class="small" style="margin:12px 0">The Director is laying it out…</div>' : `<div class="label">The Director's layout</div>` + (sh.week ? `<div class="card small" style="margin-bottom:10px;color:var(--accent)">${esc(sh.week)}</div>` : '') +
if (sh && !Object.keys(d.slots).length) body += `<div class="label">The Director's layout</div><div class="card small">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.</div>
<div style="margin-top:8px">${d.shows.map(x => `<div style="display:flex;align-items:center;gap:8px;margin-top:6px"><button data-ccore="${esc(x.id)}" style="font-size:20px;color:${d.core.includes(x.id) ? 'var(--accent)' : 'var(--muted)'}">${d.core.includes(x.id) ? '★' : '☆'}</button><b>${esc(x.title)}</b></div>`).join('')}</div>`;
else body += !sh ? '<div class="small" style="margin:12px 0">The Director is laying it out…</div>' : `<div class="label">The Director's layout</div>` +
SLOTS.map(([slot, hours]) => { const mine = d.shows.filter(x => (d.slots[x.id] || 'Evening') === slot);
return `<div class="rm"><h3>${slot}<span class="small">${hours}</span></h3>${mine.length ? mine.map(x => `<div style="display:flex;align-items:center;gap:8px;margin-top:8px">
<button data-ccore="${esc(x.id)}" aria-label="Core" style="font-size:20px;color:${d.core.includes(x.id) ? 'var(--accent)' : 'var(--muted)'}">${d.core.includes(x.id) ? '★' : '☆'}</button>
@@ -309,7 +311,8 @@ function create() {
} else {
body = `<div class="title" style="margin-top:14px">Review</div><div class="card" style="margin-top:12px"><div class="title">${esc(d.name)}</div>
<div class="small" style="margin:4px 0 10px">Yours${d.number ? ' · ' + d.number : ''} · ${STYLES.find(s => s[0] === d.style)[1]} · ${HOURS.find(([h]) => sameHours(h, d.hours))?.[1] || 'Set hours'}</div>
${d.shows.slice(0, 8).map(s => `<div style="font-weight:600">${d.core.includes(s.id) ? '★ ' : ''}${esc(s.title)}${d.style === 'Shaped' ? ` <span class="small">· ${esc(d.slots[s.id] || 'Evening')}</span>` : ''}</div>`).join('')}${d.shows.length > 8 ? `<div class="small">and ${d.shows.length - 8} more</div>` : ''}</div>` + clashCard() + note;
${d.shows.slice(0, 8).map(s => `<div style="font-weight:600">${d.core.includes(s.id) ? '★ ' : ''}${esc(s.title)}${d.style === 'Shaped' ? ` <span class="small">· ${esc(d.slots[s.id] || 'Evening')}</span>` : ''}</div>`).join('')}${d.shows.length > 8 ? `<div class="small">and ${d.shows.length - 8} more</div>` : ''}</div>` +
(c.week ? `<div class="card small" style="margin-top:12px;color:var(--accent)">${esc(c.week)}</div>` : c.shape === null ? '' : '') + clashCard() + note;
next = `<button class="btn gold" data-cmake="1">${c.busy ? 'Saving…' : d.number ? 'Save changes' : 'Create and switch on'}</button>`;
}
return top + body + `<div style="margin-top:16px">${next}</div>`;
@@ -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);
@@ -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<String>): Map<String, Long> = 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<String>): List<ShapeRow> = 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 ->
@@ -50,7 +50,9 @@ internal class ChannelMaker(
val facets: suspend () -> Pair<List<String>, List<String>> = { emptyList<String>() to emptyList() },
val shapeOf: suspend (List<String>) -> List<ShapeRow> = { 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<String, String>, number: Int?) -> Map<String, List<String>> = { _, _ -> emptyMap() })
val carriedBy: (picks: Map<String, String>, number: Int?) -> Map<String, List<String>> = { _, _ -> emptyMap() },
/** How long the picks run all told, in milliseconds, or null when the library can't say. */
val runTime: suspend (List<ShapeRow>) -> 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") }
@@ -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.
@@ -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<String> = emptySet()): List<String> {
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")
@@ -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())
@@ -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<ShapeRow>): 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<String, Slot>): List<DayPart> = 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<String, Slot>): List<DayPart> {
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<String>): Map<String, Double> = core.associate { seriesKey(it) to TIER_CORE }
@@ -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
}
}