From b26122cf46cfd05c4ca1ee80617c857a372c545a Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sat, 7 Nov 2026 08:08:01 +0000 Subject: [PATCH] Smart setup for Create a channel (from release 0.108.82) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/src/main/assets/phone/companion.html | 75 ++++++++++++++++--- .../java/tv/orbit/classic/CatalogueStore.kt | 32 ++++++++ .../java/tv/orbit/classic/CreateChannel.kt | 6 +- .../java/tv/orbit/classic/CustomChannels.kt | 22 ++++-- .../tv/orbit/classic/EmbyConnectionScreen.kt | 10 ++- .../main/java/tv/orbit/classic/PhoneLink.kt | 33 +++++++- .../main/java/tv/orbit/classic/SmartSetup.kt | 53 +++++++++++++ .../java/tv/orbit/classic/PhoneLinkTest.kt | 19 +++++ .../java/tv/orbit/classic/SmartSetupTest.kt | 41 ++++++++++ 9 files changed, 270 insertions(+), 21 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/SmartSetup.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt diff --git a/android/app/src/main/assets/phone/companion.html b/android/app/src/main/assets/phone/companion.html index 27a4d75..049774b 100644 --- a/android/app/src/main/assets/phone/companion.html +++ b/android/app/src/main/assets/phone/companion.html @@ -250,10 +250,11 @@ function more() { } // Create a channel (phase 3, from design/companion v2): five steps, Name, Shows, Style, Hours and Review, in the TV's look. // It saves through the same route as before, so the TV makes it exactly as Settings › Create your own does. -const STYLES = [['Standard','Standard TV',"As Orbit's own channels: shows take turns, episodes in order"],['Shuffled','Shuffled','Episodes in a mixed order, every one before any repeats'], +const STYLES = [['Shaped','Shaped by the Director',"Recommended. Daytime, evening and late, like Orbit's own channels, with your core shows more often"],['Standard','Standard TV',"Shows take turns all day, episodes in order"],['Shuffled','Shuffled','Episodes in a mixed order, every one before any repeats'], ['Blocks','Blocks','A timetable: each show at its own times, every day'],['Marathon','Marathon','One show at a time, several episodes back to back']]; const HOURS = [[null,'All day'],[{from:18,to:0},'Evenings, 18:00 to midnight'],[{from:6,to:12},'Mornings, 06:00 to noon'],[{from:9,to:17},'Daytime, 09:00 to 17:00']]; const STEPS = ['Name','Shows','Style','Hours','Review']; +const SLOTS = [['Daytime','06:00 to 17:00'],['Evening','17:00 to 22:00'],['Late','22:00 to 06:00']]; const sameHours = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); function create() { const c = state.create, d = c.draft; @@ -271,15 +272,35 @@ function create() { const row = (s, on) => ``; - body = `
Pick the shows
${esc(d.description || 'Describe it, or search for a title')} · ${d.shows.length} chosen
+ const b = c.browse, f = state.facets; + const chip = (key, value, label) => ``; + const finder = c.mode === 'browse' ? (!f ? '
Reading your library…
' : ` +
Type
${chip('type', '', 'Both')}${chip('type', 'Series', 'Shows')}${chip('type', 'Movie', 'Films')}
+
Genre
${chip('genre', '', 'Any')}${f.genres.map(g => chip('genre', g, g)).join('')}
+
Network or studio
${chip('studio', '', 'Any')}${f.studios.map(g => chip('studio', g, g)).join('')}
+
Age
${chip('rating', '', 'Any')}${f.ratings.map(g => chip('rating', g, g)).join('')}
+ `) : `
-
` + note + + `; + body = `
Pick the shows
${d.shows.length} chosen
+
` + finder + note + d.shows.map(s => row(s, true)).join('') + offered.map(s => row(s, false)).join('') + - (offered.length ? '
The Director suggests these from what you described and the shows you chose. Tap to add.
' : ''); + (offered.length ? `
${c.mode === 'browse' ? 'Matching titles in your library, newest first.' : 'The Director suggests these from what you described and the shows you chose.'} Tap to add.
` : ''); next = ``; } else if (c.step === 2) { body = `
How it plays
It shapes what's planned from then on, never what's already booked.
` + STYLES.map(([k, t, l]) => ``).join('') + '
'; + // 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)}
` : '') + + SLOTS.map(([slot, hours]) => { const mine = d.shows.filter(x => (d.slots[x.id] || 'Evening') === slot); + return `

${slot}${hours}

${mine.length ? mine.map(x => `
+ +
`).join('') + : '
Nothing here: these hours share all your shows.
'}
`; }).join('') + + '
★ Core shows come round more often. Everything can be changed later.
'; + } next = ''; } else if (c.step === 3) { body = `
When it's on
Outside its hours the channel shows a "back at" card.
` + @@ -288,7 +309,7 @@ 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 => `
${esc(s.title)}
`).join('')}${d.shows.length > 8 ? `
and ${d.shows.length - 8} more
` : ''}
` + 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
` : ''}
` + note; next = ``; } return top + body + `
${next}
`; @@ -304,7 +325,24 @@ async function switchTo(id, pin) { else { toast(e.message); } } } -function startCreate(draft) { state.create = {step: 0, draft, offered: [], note: '', busy: false}; render(); } +function startCreate(draft) { draft.slots = draft.slots || {}; draft.core = draft.core || []; state.create = {step: 0, draft, offered: [], note: '', busy: false, mode: 'describe', browse: {type: '', genre: '', studio: '', rating: ''}, shape: null}; render(); } +// Smart setup: what the library has to browse by, and the Director's layout for the chosen shows. +async function loadFacets() { if (state.facets) return; try { state.facets = await api('/api/facets'); } catch (e) { state.create.note = e.message; } render(); } +async function browseShows() { + const c = state.create; if (c.busy) return; c.busy = true; render(); + const q = Object.entries(c.browse).filter(([, v]) => v).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&'); + try { c.offered = await api('/api/browse?' + q); c.note = c.offered.length ? '' : 'Nothing in your library matches all of those. Try fewer.'; } catch (e) { c.note = e.message; } + c.busy = false; render(); +} +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(); } + catch (e) { c.note = e.message; } + render(); +} async function suggestShows() { const c = state.create, d = c.draft; d.description = ($('#cdesc')?.value || '').trim(); if (!d.description || c.busy) return; c.busy = true; render(); @@ -315,7 +353,8 @@ async function suggestShows() { } async function makeChannel() { const c = state.create, d = c.draft; if (c.busy) return; c.busy = true; render(); - try { const r = await api('/api/channels', {number: d.number, name: d.name, description: d.description, style: d.style, hours: d.hours, shows: d.shows}); + try { const r = await api('/api/channels', {number: d.number, name: d.name, description: d.description, style: d.style, hours: d.hours, shows: d.shows, + slots: d.style === 'Shaped' ? d.slots : undefined, core: d.style === 'Shaped' ? d.core : undefined}); state.create = null; toast(r.message || 'Saved'); load('more'); } catch (e) { c.note = e.message; c.busy = false; render(); } } @@ -401,21 +440,35 @@ document.addEventListener('click', e => { if (d.drop) { send('/api/director/drop', {id: d.drop}, j => { toast(j.message); state.director.choices = state.director.choices.filter(c => c.id !== d.drop); render(); }); return; } if (d.profile) { switchTo(d.profile); return; } if (d.pinok) { switchTo(d.pinok, $('#pin').value); return; } - if (d.create) { startCreate({name: '', description: '', style: 'Standard', hours: null, shows: [], skipped: []}); return; } + if (d.create) { startCreate({name: '', description: '', style: 'Shaped', hours: null, shows: [], skipped: []}); return; } if (d.edit) { const ch = state.channels?.channels.find(x => x.number === +d.edit); if (!ch) return; - api('/api/shows?number=' + ch.number).then(shows => startCreate({number: ch.number, name: ch.name, description: ch.description || '', style: ch.style, hours: ch.hours || null, shows, skipped: []})) + api('/api/shows?number=' + ch.number).then(shows => { + // A shaped channel keeps its layout by title; the phone works by item. + const byTitle = Object.fromEntries(shows.map(x => [x.title, x.id])), slots = {}, core = []; + Object.entries(ch.slots || {}).forEach(([t, slot]) => { if (byTitle[t]) slots[byTitle[t]] = slot; }); + (ch.core || []).forEach(t => { if (byTitle[t]) core.push(byTitle[t]); }); + startCreate({number: ch.number, name: ch.name, description: ch.description || '', style: ch.style, hours: ch.hours || null, shows, skipped: [], slots, core}); + if (Object.keys(slots).length) { state.create.coreSet = true; state.create.moved = Object.fromEntries(Object.keys(slots).map(k => [k, true])); } + }) .catch(e => toast(e.message)); return; } if (d.cback) { const c = state.create; if (c.step === 0) state.create = null; else { if ($('#cdesc')) c.draft.description = $('#cdesc').value.trim(); c.step--; c.note = ''; } render(); return; } 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); return; } + c.step++; c.note = ''; render(); scrollTo(0, 0); if (c.step === 2 && c.draft.style === 'Shaped') 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); else { const s = state.create.offered.find(s => s.id === id); if (s) dr.shows.push(s); } render(); return; } - if (d.cstyle) { state.create.draft.style = d.cstyle; render(); return; } + if (d.cstyle) { state.create.draft.style = d.cstyle; render(); if (d.cstyle === 'Shaped') shapeShows(); return; } + if (d.cmode) { state.create.mode = d.cmode; state.create.offered = []; render(); if (d.cmode === 'browse') loadFacets(); return; } + if (d.cb) { const cut = d.cb.indexOf(':'), k = d.cb.slice(0, cut), v = d.cb.slice(cut + 1); state.create.browse[k] = state.create.browse[k] === v ? '' : v; render(); return; } + if (d.cbrowse) { browseShows(); return; } + if (d.cslot) { const c = state.create, i = SLOTS.findIndex(s => s[0] === (c.draft.slots[d.cslot] || 'Evening')); + c.draft.slots[d.cslot] = SLOTS[(i + 1) % 3][0]; c.moved = {...(c.moved || {}), [d.cslot]: true}; render(); return; } + if (d.ccore) { const c = state.create, id = d.ccore; c.coreSet = true; + c.draft.core = c.draft.core.includes(id) ? c.draft.core.filter(x => x !== id) : [...c.draft.core, id].slice(-5); render(); return; } if (d.chours !== undefined) { state.create.draft.hours = HOURS[+d.chours][0]; render(); return; } if (d.cmake) { makeChannel(); return; } if (d.homedone) { try { localStorage.setItem('orbit.home', '1'); } catch {} closeSheet(); return; } 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 e4424c1..be95961 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt @@ -173,6 +173,38 @@ internal class CatalogueStore(context: Context, private val file: File) : Catalo buildList { while (c.moveToNext()) add(ChairRow(c.getString(0), c.getString(1), c.getString(2), if (c.isNull(3)) null else c.getInt(3), c.getString(4).split('|').filter { it.isNotBlank() }.toSet())) } } } + /** + * Browse for Create a channel (Paul, 7 November 2026, after other apps' channel matching): titles by genre, network or + * studio, and age rating, newest first. Read only. + */ + @Synchronized fun browseRows(genre: String?, studio: String?, ratings: Set, type: String?, limit: Int = 60): List { + val where = mutableListOf("type IN ('Movie','Series')"); val args = mutableListOf() + if (type != null) { where += "type = ?"; args += type } + if (!genre.isNullOrBlank()) { where += "('|' || genres || '|') LIKE ?"; args += "%|${genre.take(60)}|%" } + if (!studio.isNullOrBlank()) { where += "('|' || studios || '|') LIKE ?"; args += "%|${studio.take(80)}|%" } + if (ratings.isNotEmpty()) { where += "rating IN (${ratings.joinToString(",") { "?" }})"; args += ratings } + return readableDatabase.rawQuery("SELECT id, type, title, year, rating, genres FROM items WHERE ${where.joinToString(" AND ")} ORDER BY year DESC, title LIMIT $limit", args.toTypedArray()).use { c -> + 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())) } + } + } + /** 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 -> + 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())) } + } + } + @Volatile private var facetCache: Pair, List>? = null + /** The library's most common genres and networks or studios, for browsing; worked out once. Read only. */ + @Synchronized fun facets(): Pair, List> = facetCache ?: run { + val genres = HashMap(); val studios = HashMap() + readableDatabase.rawQuery("SELECT genres, studios FROM items WHERE type IN ('Movie','Series')", null).use { c -> + while (c.moveToNext()) { + c.getString(0).split('|').filter { it.isNotBlank() }.forEach { genres.merge(it, 1, Int::plus) } + c.getString(1).split('|').filter { it.isNotBlank() }.forEach { studios.merge(it, 1, Int::plus) } + } + } + (genres.entries.sortedByDescending { it.value }.take(24).map { it.key } to studios.entries.sortedByDescending { it.value }.take(40).map { it.key }).also { facetCache = it } + } /** The same rows for chosen titles, by item. Read only. */ @Synchronized fun chairRowsById(ids: Collection): List = ids.distinct().chunked(400).flatMap { chunk -> readableDatabase.rawQuery("SELECT id, type, title, year, 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 5c5e90e..34ee7b7 100644 --- a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt +++ b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt @@ -44,7 +44,11 @@ internal class ChannelMaker( /** Saves the channel and its shows and switches it on; returns what to tell you. */ val create: suspend (CustomChannel, List) -> String, /** A channel's shows as they stand, for editing. */ - val showsOf: suspend (Int) -> List = { emptyList() }) + val showsOf: suspend (Int) -> List = { emptyList() }, + /** Smart setup: browse the library, its genres and networks, and how the Director would lay chosen titles out. */ + val browse: suspend (genre: String?, studio: String?, ratings: Set, type: String?) -> List = { _, _, _, _ -> emptyList() }, + val facets: suspend () -> Pair, List> = { emptyList() to emptyList() }, + val shapeOf: suspend (List) -> List = { emptyList() }) 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/CustomChannels.kt b/android/app/src/main/java/tv/orbit/classic/CustomChannels.kt index 10f4e64..12555d0 100644 --- a/android/app/src/main/java/tv/orbit/classic/CustomChannels.kt +++ b/android/app/src/main/java/tv/orbit/classic/CustomChannels.kt @@ -25,7 +25,9 @@ internal enum class PlayStyle(val title: String, val line: String) { Shuffled("Shuffled", "Episodes in a mixed order, every one before any repeats"), /** A timetable: each show at its own hours, every day. */ Blocks("Blocks", "A timetable: each show at its own times, every day"), - Marathon("Marathon", "One show at a time, several episodes back to back") + Marathon("Marathon", "One show at a time, several episodes back to back"), + /** Smart setup ([shapeChannel]): daytime, evening and late slots as the Director laid them out, with core shows more often. */ + Shaped("Shaped by the Director", "Daytime, evening and late, like Orbit's own, with your core shows more often") } /** When a channel is on: all day, or from one hour to another (wrapping past midnight). */ @@ -36,15 +38,20 @@ internal data class OnHours(val fromHour: Int, val toHour: Int) { internal data class CustomChannel(val number: Int, val name: String, val style: PlayStyle = PlayStyle.Standard, val hours: OnHours? = null, val description: String = "", /** The shows' titles in the order they were added: Blocks' timetable follows it. The shows themselves are the accepted choices. */ - val shows: List = emptyList()) { + val shows: List = emptyList(), + /** Shaped only: each title's slot, and the core titles. */ + val slots: Map = emptyMap(), val core: Set = emptySet()) { init { require(number in YOURS_FIRST..YOURS_LAST && name.isNotBlank() && name.length <= 40) } fun toJson(): JSONObject = JSONObject().put("number", number).put("name", name).put("style", style.name).put("description", description) .put("hours", hours?.let { JSONObject().put("from", it.fromHour).put("to", it.toHour) } ?: JSONObject.NULL).put("shows", JSONArray(shows)) + .put("slots", JSONObject(slots.mapValues { it.value.name })).put("core", JSONArray(core.toList())) companion object { fun fromJson(o: JSONObject) = CustomChannel(o.getInt("number"), o.getString("name").take(40), runCatching { PlayStyle.valueOf(o.optString("style")) }.getOrDefault(PlayStyle.Standard), o.optJSONObject("hours")?.let { OnHours(it.getInt("from").coerceIn(0, 23), it.getInt("to").coerceIn(0, 24)) }, o.optString("description").take(120), - o.optJSONArray("shows")?.let { a -> (0 until minOf(a.length(), 200)).map { a.getString(it).take(160) } }.orEmpty()) + o.optJSONArray("shows")?.let { a -> (0 until minOf(a.length(), 200)).map { a.getString(it).take(160) } }.orEmpty(), + o.optJSONObject("slots")?.let { m -> m.keys().asSequence().take(200).mapNotNull { k -> runCatching { k.take(160) to Slot.valueOf(m.getString(k)) }.getOrNull() }.toMap() }.orEmpty(), + o.optJSONArray("core")?.let { a -> (0 until minOf(a.length(), 20)).map { a.getString(it).take(160) }.toSet() }.orEmpty()) } } @@ -92,11 +99,12 @@ internal object CustomChannels { */ internal fun programmingOf(channel: CustomChannel): ChannelProgramming = ChannelProgramming( channel.number, channel.name, emptyList(), seriesCount = 60, - finishTogether = channel.style == PlayStyle.Standard || channel.style == PlayStyle.Blocks, + finishTogether = channel.style == PlayStyle.Standard || channel.style == PlayStyle.Blocks || channel.style == PlayStyle.Shaped, exactTitles = true, shuffleEpisodes = channel.style == PlayStyle.Shuffled, - dayParts = if (channel.style == PlayStyle.Blocks) timetableParts(channel.shows) else emptyList(), - storyGrouping = channel.style == PlayStyle.Standard, - shorts = if (channel.style == PlayStyle.Standard) YoursShorts else null, + dayParts = when (channel.style) { PlayStyle.Blocks -> timetableParts(channel.shows); PlayStyle.Shaped -> shapedParts(channel.slots); else -> emptyList() }, + storyGrouping = channel.style == PlayStyle.Standard || channel.style == PlayStyle.Shaped, + shorts = if (channel.style == PlayStyle.Standard || channel.style == PlayStyle.Shaped) YoursShorts else null, + tierWeights = if (channel.style == PlayStyle.Shaped && channel.core.isNotEmpty()) shapedWeights(channel.core) else null, marathon = if (channel.style == PlayStyle.Marathon) MARATHON_RUN else 0) /** 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 da88b01..1d7a946 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -541,7 +541,15 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } }, search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } }, showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } }, - create = { channel, shows -> + 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) } }, + create = { asked, shows -> + // Shaped on the TV, or from the phone without a layout: the Director lays it out from the catalogue. + val channel = if (asked.style == PlayStyle.Shaped && asked.slots.isEmpty()) withContext(Dispatchers.IO) { + val rows = catalogue.shapeRows(shows.map { it.id }); val shape = shapeChannel(rows); val titleOf = rows.associate { it.id to it.title } + asked.copy(slots = shape.slots.mapNotNull { (id, slot) -> titleOf[id]?.let { it to slot } }.toMap(), core = shape.core.mapNotNull { titleOf[it] }.toSet()) + } else asked withContext(Dispatchers.IO) { CustomChannels.upsert(workspace.scheduleDir, channel) val before = scheduleStore.loadChoices(active.accountId, channel.number) 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 a881558..87095dc 100644 --- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt +++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt @@ -305,6 +305,25 @@ internal object PhoneLink { val d = r.query["d"].orEmpty().take(120) json(out, 200, JSONArray(runBlocking { h.maker.suggest(d, chosen, skipped).ifEmpty { h.maker.search(d) } }.map(::suggestionJson))) } + // Smart setup (Paul, 7 November 2026): browse the library and let the Director lay the channel out. + "GET /api/facets" -> { + val (genres, studios) = runBlocking { h.maker.facets() } + json(out, 200, JSONObject().put("genres", JSONArray(genres)).put("studios", JSONArray(studios)).put("ratings", JSONArray(RatingGroups.keys.toList()))) + } + "GET /api/browse" -> { + val rows = runBlocking { h.maker.browse(r.query["genre"]?.take(60), r.query["studio"]?.take(80), RatingGroups[r.query["rating"]].orEmpty(), r.query["type"]?.takeIf { it == "Movie" || it == "Series" }) } + json(out, 200, JSONArray(rows.map { row -> suggestionJson(Suggestion(row.id, row.title, row.year, row.type, "", + listOfNotNull(row.genres.take(2).joinToString(", ").ifEmpty { null }, row.rating.ifEmpty { null }).joinToString(" · ").ifEmpty { "In your library" })) })) + } + "POST /api/shape" -> { + val ids = runCatching { JSONObject(r.body).getJSONArray("ids").let { a -> (0 until minOf(a.length(), 200)).map { a.getString(it) }.filter(EmbyClient::validId) } }.getOrNull() + ?: return json(out, 400, JSONObject()) + 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" } + 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)) + } "POST /api/channels" -> { if (!OrbitCompanion.grownUp) return json(out, 403, JSONObject().put("error", "Kids is watching. Switch to Adult to make channels.")) val o = runCatching { JSONObject(r.body) }.getOrNull() ?: return json(out, 400, JSONObject().put("error", "Unreadable")) @@ -315,7 +334,12 @@ internal object PhoneLink { o.optJSONObject("hours")?.let { OnHours(it.getInt("from").coerceIn(0, 23), it.getInt("to").coerceIn(0, 24)) }, o.optString("description").take(120), shows.map { it.title }) }.getOrNull() ?: return json(out, 400, JSONObject().put("error", "A channel needs a name.")) if (shows.isEmpty()) return json(out, 400, JSONObject().put("error", "Add at least one show or film.")) - val said = runBlocking { withContext(mainDispatcher) { h.maker.create(channel, shows) } } + // Shaped: the phone's layout, by item, kept by title as the planner reads it. + val titleOf = shows.associate { it.id to it.title } + val laidOut = if (channel.style != PlayStyle.Shaped) channel else channel.copy( + slots = o.optJSONObject("slots")?.let { m -> m.keys().asSequence().mapNotNull { id -> titleOf[id]?.let { t -> runCatching { t to Slot.valueOf(m.getString(id)) }.getOrNull() } }.toMap() }.orEmpty(), + core = o.optJSONArray("core")?.let { a -> (0 until minOf(a.length(), 20)).mapNotNull { titleOf[a.getString(it)] }.toSet() }.orEmpty()) + val said = runBlocking { withContext(mainDispatcher) { h.maker.create(laidOut, shows) } } json(out, 200, JSONObject().put("message", said).put("number", number)) } "POST /api/watch" -> { @@ -429,3 +453,10 @@ internal object PhoneLink { } } } + +/** Age ratings by the groups the phone browses with. */ +internal val RatingGroups = mapOf( + "Kids" to setOf("TV-Y", "TV-Y7", "TV-Y7-FV", "TV-G", "G", "U"), + "Family" to setOf("PG", "TV-PG"), + "Teen" to setOf("PG-13", "TV-14", "12", "12A", "15"), + "Grown-up" to setOf("R", "TV-MA", "NC-17", "18", "R18")) diff --git a/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt b/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt new file mode 100644 index 0000000..6a9b6a7 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt @@ -0,0 +1,53 @@ +package tv.orbit.classic + +/** + * Smart setup for Create a channel (Paul, 7 November 2026: "can the chair use our logic of day time, evening slots, core + * shows"). The Director lays a new channel out the way Orbit's own are: lighter shows in the daytime, the main ones in the + * evening, grown-up and darker ones late, with a few core shows that come round more often. It's a suggestion: the + * viewer can move any show, or keep it simple. Built from the catalogue's genres, networks and age ratings, which Emby + * and Jellyfin both carry, so it works the same on either. + */ +internal enum class Slot(val label: String, val fromHour: Int, val toHour: Int) { + Daytime("Daytime", 6, 17), Evening("Evening", 17, 22), Late("Late", 22, 6) +} + +/** What the Director reads of a title to place it: its kind, age rating, genres and year. */ +internal class ShapeRow(val id: String, val type: String, val title: String, val year: Int?, val rating: String, val genres: Set) + +/** A channel laid out: each title's slot, and the core titles. */ +internal class Shape(val slots: Map, val core: Set) + +private val LateRatings = setOf("TV-MA", "R", "NC-17", "X", "18", "R18", "MA15+") +private val DayRatings = setOf("TV-Y", "TV-Y7", "TV-Y7-FV", "TV-G", "G", "U", "PG") +private val DayGenres = setOf("Children", "Kids", "Family", "Game Show", "Reality", "Talk", "Home and Garden", "Food", "Travel") + +/** Where the Director would put one title. */ +internal fun slotFor(row: ShapeRow): Slot = when { + row.rating in LateRatings || "Horror" in row.genres -> Slot.Late + row.type == "Movie" && row.genres.any { it in setOf("Thriller", "Crime", "War") } -> Slot.Late + row.rating in DayRatings || row.genres.any { it in DayGenres } -> Slot.Daytime + "Animation" in row.genres && "Anime" !in row.genres -> Slot.Daytime + "Comedy" in row.genres && (row.year ?: 3000) < 2000 -> Slot.Daytime + 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). */ +internal fun shapeChannel(rows: List): Shape { + val slots = rows.associate { it.id to slotFor(it) } + 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. */ +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." + series in 1..4 && films < 10 -> "With $series ${if (series == 1) "show" else "shows"}, the same few come round every day. A few more would make the week feel fuller." + 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) } +} +internal fun shapedWeights(core: Set): Map = core.associate { seriesKey(it) to TIER_CORE } diff --git a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt index 1e90959..f5a4f30 100644 --- a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt @@ -253,4 +253,23 @@ class PhoneLinkTest { assertEquals(403, call("/api/profile", "POST", """{"id":"nobody"}""", cookie = cookie).status) } finally { PhoneLink.screen = null; Profiles.current = before } } + + @Test fun smartSetupShapesFromTheCatalogueAndSavesTheLayoutByTitle() { + val rows = listOf(ShapeRow("a1", "Series", "Bluey", 2018, "TV-Y", setOf("Animation")), ShapeRow("a2", "Series", "Line of Duty", 2012, "TV-MA", setOf("Drama"))) + PhoneLink.handler = PhoneHandler(ChannelMaker({ _, _, _ -> emptyList() }, { emptyList() }, { c, s -> made = c to s; "${c.name} is ready" }, { emptyList() }, + browse = { genre, _, ratings, _ -> rows.filter { genre == null || genre in it.genres }.filter { ratings.isEmpty() || it.rating in ratings } }, + facets = { listOf("Drama", "Animation") to listOf("BBC One") }, shapeOf = { ids -> rows.filter { it.id in ids } }), { false }, { }) + val cookie = joinedCookie() + assertEquals("Drama", JSONObject(call("/api/facets", cookie = cookie).body).getJSONArray("genres").getString(0)) + assertEquals(1, JSONArray(call("/api/browse?rating=Kids", cookie = cookie).body).length()) + val shape = JSONObject(call("/api/shape", "POST", """{"ids":["a1","a2"]}""", cookie = cookie).body) + assertEquals("Daytime", shape.getJSONObject("slots").getString("a1")); assertEquals("Late", shape.getJSONObject("slots").getString("a2")) + assertFalse(shape.isNull("week")) // two shows: it says so + val body = """{"name":"Mixed","style":"Shaped","shows":[{"id":"a1","title":"Bluey","year":2018,"type":"Series"},{"id":"a2","title":"Line of Duty","year":2012,"type":"Series"}], + "slots":{"a1":"Evening","a2":"Late"},"core":["a2"]}""" + assertEquals(200, call("/api/channels", "POST", body, cookie = cookie).status) + val channel = made!!.first + assertEquals(PlayStyle.Shaped, channel.style) + assertEquals(mapOf("Bluey" to Slot.Evening, "Line of Duty" to Slot.Late), channel.slots); assertEquals(setOf("Line of Duty"), channel.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 new file mode 100644 index 0000000..386d07e --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt @@ -0,0 +1,41 @@ +package tv.orbit.classic + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +/** Smart setup (Paul, 7 November 2026): the Director lays a new channel out like Orbit's own, and the planner follows it. */ +class SmartSetupTest { + private fun row(id: String, type: String, rating: String, vararg genres: String, year: Int? = 2015) = ShapeRow(id, type, id, year, rating, genres.toSet()) + + @Test fun lighterShowsInTheDaytimeTheMainOnesInTheEveningDarkerOnesLate() { + assertEquals(Slot.Daytime, slotFor(row("Bluey", "Series", "TV-Y", "Animation"))) + assertEquals(Slot.Daytime, slotFor(row("Bake Off", "Series", "", "Reality"))) + assertEquals(Slot.Daytime, slotFor(row("Frasier", "Series", "TV-14", "Comedy", year = 1993))) + assertEquals(Slot.Evening, slotFor(row("Doctor Who", "Series", "TV-14", "Drama", "Science Fiction"))) + assertEquals(Slot.Late, slotFor(row("Line of Duty", "Series", "TV-MA", "Drama", "Crime"))) + assertEquals(Slot.Late, slotFor(row("Alien", "Movie", "", "Horror"))) + assertEquals(Slot.Evening, slotFor(row("Cowboy Bebop", "Series", "TV-14", "Animation", "Anime"))) + val shape = shapeChannel(listOf(row("Old", "Series", "TV-14", "Drama", year = 1990), row("New", "Series", "TV-14", "Drama", year = 2022), row("Kids", "Series", "TV-Y", "Animation"))) + assertEquals(setOf("New", "Old"), shape.core) // the evening's, newest first + } + + @Test fun theWeekCheckSpeaksUpOnlyWhenItWouldRepeat() { + assertNotNull(weekCheck(2, 0)); assertNotNull(weekCheck(0, 8)) + assertNull(weekCheck(6, 0)); assertNull(weekCheck(0, 30)); assertNull(weekCheck(0, 0)) + } + + @Test fun aShapedChannelPlansItsSlotsAndCoreAndKeepsThemWhenSaved() { + val channel = CustomChannel(701, "Mixed Bag", PlayStyle.Shaped, shows = listOf("Bluey", "Doctor Who", "Line of Duty"), + slots = mapOf("Bluey" to Slot.Daytime, "Doctor Who" to Slot.Evening, "Line of Duty" to Slot.Late), core = setOf("Doctor Who")) + val back = CustomChannel.fromJson(JSONObject(channel.toJson().toString())) + assertEquals(channel.slots, back.slots); assertEquals(channel.core, back.core) + val p = programmingOf(back) + assertEquals(listOf(6 to 17, 17 to 22, 22 to 6), p.dayParts.map { it.fromHour to it.toHour }) + assertEquals(listOf("Line of Duty"), p.dayParts.last().titles) + assertEquals(TIER_CORE, p.tierWeights!![seriesKey("Doctor Who")]) + // Other styles are as they were. + assertNull(programmingOf(channel.copy(style = PlayStyle.Standard)).tierWeights) + assertTrue(programmingOf(channel.copy(style = PlayStyle.Standard)).dayParts.isEmpty()) + } +}