Add reviewed suggestions for bigger pools (0.44.0 in testing)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user