Add reviewed suggestions for bigger pools (0.44.0 in testing)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m36s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m26s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 07:03:16 +00:00
co-authored by Claude Opus 5.5
parent 99b1d8a478
commit 64c2604f03
10 changed files with 334 additions and 23 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 44
versionName = "0.43.0-launch-retry"
versionCode = 45
versionName = "0.44.0-bigger-pools"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -1,7 +1,7 @@
package tv.orbit.classic
/** Local hours, wrapping past midnight, and the titles a channel may use then. No titles means everything in its pool. */
internal data class DayPart(val fromHour: Int, val toHour: Int, val titles: List<String> = emptyList()) {
internal data class DayPart(val fromHour: Int, val toHour: Int, val titles: List<String> = emptyList(), val group: String = "") {
fun covers(hour: Int) = if (fromHour <= toHour) hour in fromHour until toHour else hour >= fromHour || hour < toHour
}
@@ -13,7 +13,8 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se
val finishTogether: Boolean = false,
/** Only the exact title counts, for channels whose short names could match something unrelated. */
val exactTitles: Boolean = false,
val dayParts: List<DayPart> = emptyList()) {
val dayParts: List<DayPart> = emptyList(),
val suggestionRules: List<SuggestionRule> = emptyList()) {
/** Changes when the list does, so the episode cache is read again. What's already committed stays. */
val signature get() = "${searches.joinToString("|")}#$seriesCount"
}
@@ -25,11 +26,19 @@ internal val SmartChannels = listOf(
"Poltergeist", "The Howling", "Creepshow", "Prince of Darkness"), seriesCount = 40, filmRestMs = 48 * 60 * 60_000L,
dayParts = listOf(DayPart(21, 5), DayPart(5, 21, listOf("The 'Burbs", "'Til Death Do Us Part", "Tremors", "The Lost Boys", "Fright Night", "Poltergeist", "Scream",
"Escape from New York", "They Live", "Repo Man", "Donnie Darko", "From Dusk Till Dawn", "Creepshow", "Near Dark", "Halloween", "The Fog", "Blue Velvet",
"The Wicker Man", "An American Werewolf in London", "Prince of Darkness")))),
"The Wicker Man", "An American Werewolf in London", "Prince of Darkness"), group = "day")),
suggestionRules = listOf(
SuggestionRule("day", "Daytime", "Movie", listOf("Horror", "Comedy"), 1975..2010, 6f, setOf("NC-17", "X"), 40),
SuggestionRule("day", "Daytime", "Movie", listOf("Science Fiction", "Action"), 1975..2005, 6.5f, setOf("NC-17", "X"), 40),
SuggestionRule("day", "Daytime", "Movie", listOf("Thriller", "Mystery"), 1975..2005, 6.5f, setOf("NC-17", "X"), 30),
SuggestionRule("late", "Late night", "Movie", listOf("Horror"), 1970..2010, 6f, emptySet(), 90))),
ChannelProgramming(103, "Vortex", listOf("Dragon Ball Z", "Batman Beyond", "Superman: The Animated Series", "Naruto", "Gundam Wing", "Yu Yu Hakusho", "Samurai Jack"),
seriesCount = 6, finishTogether = true,
dayParts = listOf(DayPart(6, 18, listOf("Batman Beyond", "Superman: The Animated Series", "Samurai Jack")),
DayPart(18, 6, listOf("Dragon Ball Z", "Naruto", "Yu Yu Hakusho", "Gundam Wing")))),
dayParts = listOf(DayPart(6, 18, listOf("Batman Beyond", "Superman: The Animated Series", "Samurai Jack"), group = "day"),
DayPart(18, 6, listOf("Dragon Ball Z", "Naruto", "Yu Yu Hakusho", "Gundam Wing"), group = "night")),
suggestionRules = listOf(
SuggestionRule("day", "Daytime", "Series", listOf("Animation", "Action"), 1985..2015, 7f, setOf("TV-MA"), 30),
SuggestionRule("night", "Evening", "Series", listOf("Anime"), 1985..2020, 7.5f, emptySet(), 20))),
ChannelProgramming(140, "Terra", listOf("The Blue Planet", "Blue Planet II", "Planet Earth", "Planet Earth II", "Planet Earth III", "Frozen Planet", "Frozen Planet II",
"Life on Earth", "The Living Planet", "The Trials of Life", "Our Planet", "Our Planet II", "Dynasties", "Dynasties II", "Seven Worlds, One Planet", "A Perfect Planet",
"Hostile Planet", "Africa", "Wild China", "South Pacific", "Great Barrier Reef", "The Hunt", "The Life of Mammals", "The Life of Birds", "Nature's Great Events", "Green Planet", "Prehistoric Planet"),
@@ -40,7 +49,7 @@ internal data class EpisodeMark(val season: Int, val episode: Int) : Comparable<
override fun compareTo(other: EpisodeMark) = compareValuesBy(this, other, { it.season }, { it.episode })
}
internal data class PoolEpisode(val id: String, val sourceId: String, val mark: EpisodeMark, val title: String, val durationMs: Long)
internal data class PoolSeries(val id: String, val title: String, val episodes: List<PoolEpisode>)
internal data class PoolSeries(val id: String, val title: String, val episodes: List<PoolEpisode>, val group: String = "")
/** Where a channel has got to: the last episode of each show, and whose turn was last. */
internal data class ScheduleCursor(val lastSeriesId: String? = null, val marks: Map<String, EpisodeMark> = emptyMap(),
@@ -70,9 +79,10 @@ internal fun nextEpisode(series: PoolSeries, last: EpisodeMark?): PoolEpisode =
*/
internal fun dayPartPool(pool: List<PoolSeries>, parts: List<DayPart>, startMs: Long, zone: java.time.ZoneId): List<PoolSeries> {
val hour = java.time.Instant.ofEpochMilli(startMs).atZone(zone).hour
val titles = parts.firstOrNull { it.covers(hour) }?.titles.orEmpty().map(::seriesKey).toSet()
if (titles.isEmpty()) return pool
return pool.filter { seriesKey(it.title) in titles }.ifEmpty { pool }
val part = parts.firstOrNull { it.covers(hour) } ?: return pool
if (part.titles.isEmpty() && part.group.isEmpty()) return pool
val titles = part.titles.map(::seriesKey).toSet()
return pool.filter { seriesKey(it.title) in titles || (part.group.isNotEmpty() && it.group == part.group) }.ifEmpty { pool }
}
/** The next show after [last] in the rota that [eligible] allows. */
@@ -34,17 +34,19 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
val number = programming.number
val now = clock()
val cached = store.loadPool(userId, number)
val choices = store.loadChoices(userId, number)
val signature = "${programming.signature}#${choices.tag}"
var pool = cached
var requests = 0
var fetchMs = 0L
if (cached == null || now - cached.fetchedAtMs >= POOL_REFRESH_MS || cached.signature != programming.signature) {
if (cached == null || now - cached.fetchedAtMs >= POOL_REFRESH_MS || cached.signature != signature) {
val started = android.os.SystemClock.elapsedRealtime()
try {
val read = session.readChannelPool(programming, loop, checkActive)
val read = session.readChannelPool(programming, loop, choices, checkActive)
requests = read.requests
fetchMs = android.os.SystemClock.elapsedRealtime() - started
if (read.series.isNotEmpty()) {
pool = ScheduleStore.SavedPool(read.series, now, programming.signature)
pool = ScheduleStore.SavedPool(read.series, now, signature)
store.savePool(userId, number, pool)
}
} catch (failure: EmbyCheckException) { if (cached == null) throw failure }
@@ -0,0 +1,115 @@
package tv.orbit.classic
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import java.net.URLEncoder
/** A plain rule for what suits a channel. Every suggestion it makes can be explained from these fields alone. */
internal data class SuggestionRule(val group: String, val label: String, val type: String, val genres: List<String>,
val years: IntRange, val minRating: Float, val excludeRatings: Set<String> = emptySet(), val limit: Int)
internal data class Suggestion(val id: String, val title: String, val year: Int?, val type: String, val group: String, val reason: String,
val sourceId: String? = null, val durationMs: Long? = null)
internal class ChannelChoices(val accepted: List<Suggestion> = emptyList(), val rejected: Set<String> = emptySet()) {
/** Part of the cache signature, so choosing different titles reads them in. */
val tag get() = accepted.map { it.id }.sorted().joinToString(",").hashCode().toString()
}
internal const val SUGGESTION_PAGE = 50
internal const val SUGGESTION_MAX_PAGES = 3
internal class SuggestionRead(val suggestions: List<Suggestion>, val requests: Int)
/** The first playable copy of a title, as (source id, runtime). */
internal fun playableSource(row: org.json.JSONObject): Pair<String, Long>? {
val sources = row.optJSONArray("MediaSources") ?: return null
val source = (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) }
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) } ?: return null
val ticks = source.optLong("RunTimeTicks", 0).takeIf { it > 0 } ?: row.optLong("RunTimeTicks", 0)
val duration = (ticks / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } ?: return null
return source.getString("Id") to duration
}
/** Highest rated first, in bounded pages, one rule at a time. A title is never suggested twice, and a rejected one never again. */
internal fun findSuggestions(transport: EmbyTransport, userId: String, token: () -> String, rules: List<SuggestionRule>,
alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}): SuggestionRead {
require(EmbyClient.validId(userId))
val found = LinkedHashMap<String, Suggestion>()
val seen = mutableSetOf<String>()
var requests = 0
for (rule in rules) {
var taken = 0
for (page in 0 until SUGGESTION_MAX_PAGES) {
if (taken >= rule.limit) break
checkActive()
requests++
val genre = URLEncoder.encode(rule.genres.first(), "UTF-8")
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=${rule.type}&Genres=$genre&Years=${rule.years.joinToString(",")}" +
"&MinCommunityRating=${rule.minRating}&SortBy=CommunityRating&SortOrder=Descending&StartIndex=${page * SUGGESTION_PAGE}&Limit=$SUGGESTION_PAGE" +
"&Fields=Genres,MediaSources&EnableImages=false&EnableUserData=false", token = token(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS))
EmbyClient.expectSuccess(reply.status, false)
val rows = EmbyClient.parse(reply.body).optJSONArray("Items") ?: break
for (index in 0 until rows.length()) {
if (taken >= rule.limit) break
val row = rows.optJSONObject(index) ?: continue
val id = row.optString("Id")
val title = EmbyClient.clean(row.optString("Name"), 160)
val year = row.optInt("ProductionYear").takeIf { it in 1800..2200 }
if (!EmbyClient.validId(id) || title.isBlank() || row.optString("Type") != rule.type || id in alreadyInPool || id in rejected || id in found) continue
val tags = row.optJSONArray("Genres")
val genres = (0 until (tags?.length() ?: 0)).mapNotNull { tags?.optString(it) }.map { it.lowercase() }
if (rule.genres.any { it.lowercase() !in genres }) continue
if (row.optString("OfficialRating") in rule.excludeRatings) continue
if (!seen.add("${seriesKey(title)}#$year")) continue
val source = if (rule.type == "Movie") playableSource(row) ?: continue else null
val score = row.optDouble("CommunityRating", 0.0)
val reason = "${rule.label}: ${rule.genres.joinToString(" and ")}, ${year ?: "year unknown"}, rated ${"%.1f".format(java.util.Locale.UK, score)}"
found[id] = Suggestion(id, title, year, rule.type, rule.group, reason, source?.first, source?.second)
taken++
}
if (rows.length() < SUGGESTION_PAGE) break
}
}
return SuggestionRead(found.values.toList(), requests)
}
/** You choose what joins the channel: untick anything that doesn't belong, and it won't be suggested again. */
@Composable
internal fun SuggestionsScreen(programming: ChannelProgramming, suggestions: List<Suggestion>, ticked: Set<String>, busy: Boolean, message: String?, modifier: Modifier,
onToggle: (String) -> Unit, onAll: (Boolean) -> Unit, onSave: (Boolean) -> Unit, onBack: () -> Unit) {
BackHandler(onBack = onBack)
Column(modifier.fillMaxWidth().background(Navy).padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${programming.number} ${programming.name} suggestions", color = Color.White, fontSize = 20.sp)
Text("${ticked.size} of ${suggestions.size} ticked. Anything you untick won't be suggested again.", color = Muted, fontSize = 13.sp)
message?.let { Text(it, color = Yellow, fontSize = 13.sp, maxLines = 3) }
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OrbitButton("Save and change the guide now", enabled = !busy && suggestions.isNotEmpty(), onClick = { onSave(true) })
OrbitButton("Save, keep this week's guide", enabled = !busy && suggestions.isNotEmpty(), onClick = { onSave(false) })
OrbitButton("Tick all", enabled = !busy, onClick = { onAll(true) })
OrbitButton("Untick all", enabled = !busy, onClick = { onAll(false) })
OrbitButton("Back", onClick = onBack)
}
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
items(suggestions, key = { it.id }) { suggestion ->
Row(Modifier.fillMaxWidth().background(Panel).padding(horizontal = 8.dp, vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OrbitButton(if (suggestion.id in ticked) "✓ In" else "Out", Modifier.width(70.dp), selected = suggestion.id in ticked, enabled = !busy, onClick = { onToggle(suggestion.id) })
Column(Modifier.weight(1f)) {
Text(suggestion.title + (suggestion.year?.let { " ($it)" } ?: ""), color = Color.White, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(suggestion.reason, color = Muted, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
}
@@ -41,7 +41,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
connection.connectTimeout = 10_000
connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.43.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.44.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -97,6 +97,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val scheduleStore = remember { ScheduleStore(java.io.File(appContext.noBackupFilesDir, "schedule")) }
val scheduler = remember { ChannelScheduler(scheduleStore) }
var scheduledChannels by remember { mutableStateOf<Map<Int, ScheduledChannel>>(emptyMap()) }
var suggesting by remember { mutableStateOf<ChannelProgramming?>(null) }
var suggestions by remember { mutableStateOf<List<Suggestion>>(emptyList()) }
var tickedSuggestions by remember { mutableStateOf<Set<String>>(emptySet()) }
var lastSmartPress by remember { mutableLongStateOf(0L) }
fun onAir(loop: SampleChannel) = scheduledChannels[loop.number]?.takeIf { it.loop === loop } ?: loop
var channelClockOffset by remember { mutableLongStateOf(0) }
@@ -472,6 +475,57 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
finally { busy = false }
}
}
fun openSuggestions(programming: ChannelProgramming) {
val active = session ?: return
if (busy) return
suggesting = programming; suggestions = emptyList(); tickedSuggestions = emptySet()
busy = true; message = "Checking your library for titles that suit ${programming.name}…"
job = scope.launch {
try {
val started = android.os.SystemClock.elapsedRealtime()
val choices = withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, programming.number) }
val inPool = withContext(Dispatchers.IO) { scheduleStore.loadPool(active.accountId, programming.number)?.series.orEmpty().map { it.id }.toSet() } - choices.accepted.map { it.id }.toSet()
val read = withContext(Dispatchers.IO) {
val context = coroutineContext
active.suggestFor(programming.suggestionRules, inPool + choices.accepted.map { it.id }, choices.rejected) { context.ensureActive() }
}
android.util.Log.i("OrbitSchedule", "channel=${programming.number} suggestions=${read.suggestions.size} requests=${read.requests} suggestMs=${android.os.SystemClock.elapsedRealtime() - started}")
suggestions = choices.accepted + read.suggestions
tickedSuggestions = suggestions.map { it.id }.toSet()
message = if (suggestions.isEmpty()) "Nothing new matched these rules." else "${read.suggestions.size} new suggestions."
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "The suggestions could not be checked. Try again later." }
finally { busy = false }
}
}
fun saveSuggestions(programming: ChannelProgramming, changeNow: Boolean) {
val active = session ?: return
val loop = smartLoop(programming) ?: return
if (busy) return
busy = true; message = "Saving your choices and reading what's new…"
val accepted = suggestions.filter { it.id in tickedSuggestions }
val left = suggestions.filter { it.id !in tickedSuggestions }.map { it.id }
job = scope.launch {
try {
val report = withContext(Dispatchers.IO) {
val context = coroutineContext
val before = scheduleStore.loadChoices(active.accountId, programming.number)
scheduleStore.saveChoices(active.accountId, programming.number, ChannelChoices(accepted, before.rejected + left))
if (changeNow) scheduleStore.deletePlan(programming.number)
scheduler.setEnabled(active.accountId, programming.number, true)
scheduler.topUp(active, active.accountId, programming, loop) { context.ensureActive() }.also {
scheduler.load(active.accountId, loop)?.let { on -> scheduledChannels = scheduledChannels + (programming.number to on) }
}
}
suggesting = null
message = "${programming.name} now has ${report.shows.size} titles and ${report.programmes} programmes planned. ${report.requests} requests, ${report.fetchMs / 1000} s to read, ${report.bytes / 1024} KB stored."
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "Your choices were saved, but ${programming.name} couldn't be updated. Try again later." }
finally { busy = false }
}
}
fun checkMoreSeries() {
val active = session ?: return
if (busy) return
@@ -492,6 +546,14 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
finally { busy = false }
}
}
suggesting?.let { programming ->
SuggestionsScreen(programming, suggestions, tickedSuggestions, busy, message, modifier,
onToggle = { id -> tickedSuggestions = if (id in tickedSuggestions) tickedSuggestions - id else tickedSuggestions + id },
onAll = { all -> tickedSuggestions = if (all) suggestions.map { it.id }.toSet() else emptySet() },
onSave = { changeNow -> saveSuggestions(programming, changeNow) },
onBack = { job?.cancel(); busy = false; suggesting = null })
return
}
if (reviewingSeries) {
SeriesReviewScreen(seriesMatches.orEmpty(), approvedSeries, episodeSamples, busy, message, modifier,
onToggle = { id ->
@@ -662,6 +724,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val on = scheduledChannels.containsKey(programming.number)
OrbitButton("${programming.number} ${programming.name} — smart schedule is ${if (on) "ON. Press to go back to its loop" else "OFF. Press to turn on"}", Modifier.fillMaxWidth(),
enabled = !busy && session != null && smartLoop(programming) != null, onClick = { setSmartSchedule(programming, !on) })
if (programming.suggestionRules.isNotEmpty()) OrbitButton("${programming.number} ${programming.name} — bigger pool: review suggestions", Modifier.fillMaxWidth(),
enabled = !busy && session != null && smartLoop(programming) != null, onClick = { openSuggestions(programming) })
}
OrbitButton("Check metadata coverage", Modifier.fillMaxWidth(),
enabled = !busy && session != null, onClick = { message = null; reviewingCoverage = true })
@@ -186,7 +186,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
internal class PoolRead(val series: List<PoolSeries>, val requests: Int)
/** The first shows in the channel's list that have episodes to schedule. */
fun readChannelPool(programming: ChannelProgramming, loop: SampleChannel, checkActive: () -> Unit = {}): PoolRead {
fun readChannelPool(programming: ChannelProgramming, loop: SampleChannel, choices: ChannelChoices = ChannelChoices(), checkActive: () -> Unit = {}): PoolRead {
var requests = 0
val pool = mutableListOf<PoolSeries>()
if (programming.filmRestMs != null) {
@@ -199,9 +199,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
val film = findFilm(transport, userId, ::currentToken, title) ?: continue
if (pool.none { it.id == film.id }) pool += film
}
return PoolRead(pool, requests)
}
for (search in programming.searches) {
} else for (search in programming.searches) {
if (pool.size == programming.seriesCount) break
checkActive()
requests++
@@ -211,8 +209,22 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
val whole = readWholeSeries(transport, userId, ::currentToken, match, checkActive, onRequest = { requests++ })
if (whole.episodes.isNotEmpty()) pool += whole
}
for (accepted in choices.accepted) {
checkActive()
if (pool.any { it.id == accepted.id }) continue
if (accepted.type == "Movie") {
val source = accepted.sourceId ?: continue
val duration = accepted.durationMs ?: continue
pool += PoolSeries(accepted.id, accepted.title, listOf(PoolEpisode(accepted.id, source, EpisodeMark(1, 1), accepted.title, duration)), accepted.group)
} else {
val whole = readWholeSeries(transport, userId, ::currentToken, SeriesCandidate(accepted.id, accepted.title, accepted.year, ""), checkActive, onRequest = { requests++ })
if (whole.episodes.isNotEmpty()) pool += whole.copy(group = accepted.group)
}
}
return PoolRead(pool, requests)
}
fun suggestFor(rules: List<SuggestionRule>, alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}) =
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive)
fun rememberScheduledItems(items: List<SampleItem>) { channelEpisodes = channelEpisodes + items.associateBy { it.id } }
fun checkMetadataCoverage(checkActive: () -> Unit = {}): List<MetadataCoverage> =
@@ -83,7 +83,7 @@ internal class ScheduleStore(private val directory: File) {
pool.series.forEach { show ->
val episodes = JSONArray()
show.episodes.forEach { e -> episodes.put(JSONArray().put(e.id).put(e.sourceId).put(e.mark.season).put(e.mark.episode).put(e.title).put(e.durationMs)) }
series.put(JSONObject().put("id", show.id).put("title", show.title).put("episodes", episodes))
series.put(JSONObject().put("id", show.id).put("title", show.title).put("episodes", episodes).put("group", show.group))
}
write(file("episodes", number), root(userId, number).put("fetched", pool.fetchedAtMs).put("signature", pool.signature).put("series", series))
}
@@ -95,12 +95,32 @@ internal class ScheduleStore(private val directory: File) {
PoolSeries(validId(show.getString("id")), show.getString("title"), (0 until episodes.length()).map { row ->
val e = episodes.getJSONArray(row)
PoolEpisode(validId(e.getString(0)), validId(e.getString(1)), EpisodeMark(e.getInt(2), e.getInt(3)), e.getString(4), validDuration(e.getLong(5)))
}.also { require(it.isNotEmpty()) })
}.also { require(it.isNotEmpty()) }, show.optString("group"))
}, root.getLong("fetched"), root.optString("signature"))
}
/** Your accepted and rejected suggestions. Kept apart from the schedule, so a rebuild or a damaged plan never loses them. */
fun saveChoices(userId: String, number: Int, choices: ChannelChoices) {
val accepted = JSONArray()
choices.accepted.forEach { s -> accepted.put(JSONObject().put("i", s.id).put("t", s.title).put("y", s.year ?: JSONObject.NULL).put("k", s.type).put("g", s.group)
.put("r", s.reason).put("c", s.sourceId ?: JSONObject.NULL).put("d", s.durationMs ?: JSONObject.NULL)) }
write(file("choices", number), root(userId, number).put("accepted", accepted).put("rejected", JSONArray(choices.rejected.toList())))
}
fun loadChoices(userId: String, number: Int): ChannelChoices = read(file("choices", number), userId, number) { root ->
val rows = root.getJSONArray("accepted")
val rejected = root.getJSONArray("rejected")
ChannelChoices((0 until rows.length()).map { index ->
val o = rows.getJSONObject(index)
Suggestion(validId(o.getString("i")), o.getString("t"), if (o.isNull("y")) null else o.getInt("y"), o.getString("k").also { require(it in listOf("Movie", "Series")) }, o.getString("g"),
o.getString("r"), if (o.isNull("c")) null else validId(o.getString("c")), if (o.isNull("d")) null else validDuration(o.getLong("d")))
}, (0 until rejected.length()).map { validId(rejected.getString(it)) }.toSet())
} ?: ChannelChoices()
/** Only when you ask for the guide to change now. The saved place and your choices stay. */
fun deletePlan(number: Int) { listOf("schedule-$number.json", "schedule-$number.json.prev").forEach { File(directory, it).delete() } }
/** Bytes held for a channel, for the measurements. */
fun sizes(number: Int) = listOf("episodes", "schedule", "progress").associateWith { file(it, number).length() }
fun sizes(number: Int) = listOf("episodes", "schedule", "progress", "choices").associateWith { file(it, number).length() }
/** On forgetting the account. Rollback never needs this: the channel simply goes back to its loop. */
fun clear() { directory.listFiles()?.forEach { it.delete() } }
@@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B
next.instanceFollowRedirects = false
next.connectTimeout = 10_000
next.readTimeout = 20_000
next.setRequestProperty("User-Agent", "OrbitTV/0.43.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.44.0")
next.setRequestProperty("Accept-Encoding", "identity")
session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) }
if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) {
@@ -0,0 +1,88 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class ChannelSuggestionsTest {
@get:Rule val folder = TemporaryFolder()
private val user = "fixture-user"
private val rule = SuggestionRule("day", "Daytime", "Movie", listOf("Horror", "Comedy"), 1975..2010, 6f, setOf("NC-17"), 40)
private fun row(id: String, name: String, genres: List<String>, year: Int = 1985, rating: Double = 7.2, official: String = "R", container: String = "mkv") = JSONObject().put("Id", id).put("Type", "Movie").put("Name", name)
.put("ProductionYear", year).put("CommunityRating", rating).put("OfficialRating", official).put("RunTimeTicks", 60_000_000_000L).put("Genres", JSONArray(genres))
.put("MediaSources", JSONArray().put(JSONObject().put("Id", "s$id").put("Container", container)))
private fun reply(vararg rows: JSONObject) = EmbyTransport { EmbyReply(200, JSONObject().put("Items", JSONArray(rows.toList())).toString()) }
@Test fun onlyTitlesMeetingEveryPartOfTheRuleAreSuggestedWithAReason() {
val read = findSuggestions(reply(row("a", "Good", listOf("Horror", "Comedy")), row("b", "Half", listOf("Horror")), row("c", "Banned", listOf("Horror", "Comedy"), official = "NC-17"), row("d", "Unplayable", listOf("Horror", "Comedy"), container = "iso")),
user, { "t" }, listOf(rule), emptySet(), emptySet())
assertEquals(listOf("a"), read.suggestions.map { it.id })
assertEquals("Daytime: Horror and Comedy, 1985, rated 7.2", read.suggestions.single().reason)
assertEquals("sa", read.suggestions.single().sourceId)
}
@Test fun rejectedPooledAndDuplicateTitlesAreLeftOut() {
val rows = arrayOf(row("a", "Rejected", listOf("Horror", "Comedy")), row("b", "Pooled", listOf("Horror", "Comedy")), row("c", "Copy", listOf("Horror", "Comedy")), row("d", "Copy", listOf("Horror", "Comedy")))
assertEquals(listOf("c"), findSuggestions(reply(*rows), user, { "t" }, listOf(rule), setOf("b"), setOf("a")).suggestions.map { it.id })
}
@Test fun requestsAreBoundedAndOnePageEndsTheRule() {
val requests = mutableListOf<EmbyRequest>()
val full = JSONArray((1..SUGGESTION_PAGE).map { row("x$it", "Film $it", listOf("Other")) })
val read = findSuggestions(EmbyTransport { requests += it; EmbyReply(200, JSONObject().put("Items", full).toString()) }, user, { "tok" }, listOf(rule), emptySet(), emptySet())
assertEquals(SUGGESTION_MAX_PAGES, read.requests)
assertTrue(requests.all { it.path.contains("Limit=$SUGGESTION_PAGE") && it.path.contains("SortBy=CommunityRating") && !it.path.contains("tok") })
assertEquals(1, findSuggestions(reply(row("a", "One", listOf("Horror", "Comedy"))), user, { "t" }, listOf(rule), emptySet(), emptySet()).requests)
}
@Test fun aRuleStopsAtItsLimit() {
val rows = (1..20).map { row("f$it", "Film $it", listOf("Horror", "Comedy")) }.toTypedArray()
assertEquals(5, findSuggestions(reply(*rows), user, { "t" }, listOf(rule.copy(limit = 5)), emptySet(), emptySet()).suggestions.size)
}
@Test fun choicesSurviveASaveAndAReplan() {
val store = ScheduleStore(folder.newFolder())
val accepted = Suggestion("a", "Film", 1985, "Movie", "day", "reason", "sa", 60_000)
store.saveChoices(user, 102, ChannelChoices(listOf(accepted), setOf("b")))
store.deletePlan(102)
val read = store.loadChoices(user, 102)
assertEquals(listOf(accepted), read.accepted)
assertEquals(setOf("b"), read.rejected)
}
@Test fun noChoicesFileMeansNoChoices() {
val read = ScheduleStore(folder.newFolder()).loadChoices(user, 102)
assertTrue(read.accepted.isEmpty() && read.rejected.isEmpty())
}
@Test fun changingTheAcceptedTitlesChangesTheCacheTag() {
val a = Suggestion("a", "A", 1985, "Movie", "day", "", "s", 1_000)
assertNotEquals(ChannelChoices().tag, ChannelChoices(listOf(a)).tag)
assertEquals(ChannelChoices(listOf(a), setOf("x")).tag, ChannelChoices(listOf(a), setOf("y")).tag)
}
@Test fun acceptedTitlesJoinTheirBlockByGroup() {
val london = java.time.ZoneId.of("Europe/London")
val at = { h: Int -> java.time.ZonedDateTime.of(2026, 11, 2, h, 0, 0, 0, london).toInstant().toEpochMilli() }
fun film(id: String, group: String) = PoolSeries(id, id, listOf(PoolEpisode(id, "s$id", EpisodeMark(1, 1), id, 60_000)), group)
val pool = listOf(film("named", ""), film("dayFilm", "day"), film("lateFilm", "late"))
val parts = listOf(DayPart(21, 5), DayPart(5, 21, listOf("named"), "day"))
assertEquals(listOf("named", "dayFilm"), dayPartPool(pool, parts, at(12), london).map { it.id })
assertEquals(3, dayPartPool(pool, parts, at(23), london).size)
}
@Test fun thePoolFileKeepsEachTitlesGroup() {
val store = ScheduleStore(folder.newFolder())
val film = PoolSeries("a", "A", listOf(PoolEpisode("a", "sa", EpisodeMark(1, 1), "A", 60_000)), "day")
store.savePool(user, 102, ScheduleStore.SavedPool(listOf(film), 5L, "sig"))
assertEquals("day", store.loadPool(user, 102)!!.series.single().group)
}
@Test fun afterDarkAndVortexBlocksAcceptTheirGroups() {
assertEquals("day", SmartChannels.first { it.number == 102 }.dayParts[1].group)
assertEquals(listOf("day", "night"), SmartChannels.first { it.number == 103 }.dayParts.map { it.group })
}
}