diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ae07991..d360d33 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index 2aaf877..efc4fcb 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -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 = emptyList()) { +internal data class DayPart(val fromHour: Int, val toHour: Int, val titles: List = 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 = emptyList()) { + val dayParts: List = emptyList(), + val suggestionRules: List = 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) +internal data class PoolSeries(val id: String, val title: String, val episodes: List, 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 = emptyMap(), @@ -70,9 +79,10 @@ internal fun nextEpisode(series: PoolSeries, last: EpisodeMark?): PoolEpisode = */ internal fun dayPartPool(pool: List, parts: List, startMs: Long, zone: java.time.ZoneId): List { 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. */ diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 9b15515..e9bb9db 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt new file mode 100644 index 0000000..85a1659 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt @@ -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, + val years: IntRange, val minRating: Float, val excludeRatings: Set = 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 = emptyList(), val rejected: Set = 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, val requests: Int) + +/** The first playable copy of a title, as (source id, runtime). */ +internal fun playableSource(row: org.json.JSONObject): Pair? { + 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, + alreadyInPool: Set, rejected: Set, checkActive: () -> Unit = {}): SuggestionRead { + require(EmbyClient.validId(userId)) + val found = LinkedHashMap() + val seen = mutableSetOf() + 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, ticked: Set, 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) + } + } + } + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 14a3e4a..92afb9f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) 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 7e34b71..8911f76 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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>(emptyMap()) } + var suggesting by remember { mutableStateOf(null) } + var suggestions by remember { mutableStateOf>(emptyList()) } + var tickedSuggestions by remember { mutableStateOf>(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 }) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index 337eabe..ffc39fc 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -186,7 +186,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v internal class PoolRead(val series: List, 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() 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, alreadyInPool: Set, rejected: Set, checkActive: () -> Unit = {}) = + findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive) fun rememberScheduledItems(items: List) { channelEpisodes = channelEpisodes + items.associateBy { it.id } } fun checkMetadataCoverage(checkActive: () -> Unit = {}): List = diff --git a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt index 7b8c63a..110130a 100644 --- a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt @@ -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() } } diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index c787500..aad2303 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt new file mode 100644 index 0000000..205b7e9 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt @@ -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, 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() + 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 }) + } +}