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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user