Add library health check, channel logos and remote controls (0.22.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m41s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m23s

A read-only check of duplicates, decades, genres and filter speed, with
no database change. Gives 101, 102 and 103 their logos and an on-screen
mark, and makes up, down and left change channel while watching.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 21:59:35 +00:00
co-authored by Claude Opus 5.5
parent 8cc94bee2f
commit 232ebfdc84
22 changed files with 252 additions and 21 deletions
+15
View File
@@ -0,0 +1,15 @@
# Orbit TV 0.22.0 — library health check and test channel
Approved scope: a read-only baseline of the imported catalogue, with no migration, plus channel logos and Paul's remote controls on the existing channels.
Library health check: Menu → Connect Emby → Library import test → Health check → Run check. One pass over saved rows counts titles, likely copies by external ID (Imdb, then Tmdb, then Tvdb, per type), distinct titles by normalised title and year, decades, top genres and tags, then times five filters. It uses readableDatabase, writes nothing, stays at database version 2 and makes no network request. Counts are "likely": title matching can over-group or under-group, and the check merges nothing. The pass holds short keys in memory; its memory use on the box wasn't measured.
Channels: 101 shows as Orbit 1 and 103 as Vortex, display only, as approved in the roadmap. Saved names and numbers are unchanged, so timelines are too. All three now have a guide logo, background and tuning screen, and a bottom-left mark at 80% opacity, hidden while the programme bar is up. There's no setting for the mark yet, so it's always on for this test.
Remote while watching a channel: up and channel-up go to the next channel, down and channel-down to the previous one, wrapping round. Left returns to the last channel. The programme bar's legend now matches, and its subtitle label is gone because that control isn't built. On-demand sample playback keeps its seek behaviour.
Not in this build: idents in the app, subtitles on right, the hold-OK number box, the new home screen, or any use of the catalogue by channels.
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 119 unit tests (113 existing, 6 new), zero failures or errors, no lint errors. New tests cover copy grouping, remakes, spelling, broken IDs, film and series sharing a number, counts, and channel wrap-round. SQLite queries, the screen, the mark on real video and the remote need the Xiaomi test. No private account, catalogue, media or device data was read by tools.
Rollback: reinstall outputs/revision-0.21.0, as the database and saved format are unchanged, or revert this commit.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 21
versionName = "0.21.0-import-position"
versionCode = 22
versionName = "0.22.0-library-health"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -21,7 +21,7 @@ private fun whole(value: Long) = String.format(Locale.UK, "%,d", value)
@Composable
internal fun CatalogueScreen(summary: CatalogueSummary?, busy: Boolean, canRun: Boolean, message: String?, lastRun: String?,
modifier: Modifier, onRun: (Boolean) -> Unit, onBack: () -> Unit) {
modifier: Modifier, onRun: (Boolean) -> Unit, onBack: () -> Unit, onHealth: () -> Unit = {}) {
BackHandler(onBack = onBack)
val backFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { backFocus.requestFocus() }
@@ -37,6 +37,7 @@ internal fun CatalogueScreen(summary: CatalogueSummary?, busy: Boolean, canRun:
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OrbitButton(if (busy) "Importing…" else if (summary?.finished == true) "Import finished" else "Import everything", enabled = canRun && summary?.finished != true, onClick = { onRun(true) })
OrbitButton("Import next 1,000", enabled = canRun && summary?.finished != true, onClick = { onRun(false) })
OrbitButton("Health check", enabled = !busy, onClick = onHealth)
OrbitButton("Back", Modifier.focusRequester(backFocus), onClick = onBack)
}
message?.let { Text(it, color = Yellow, fontSize = 15.sp, maxLines = 3) }
@@ -0,0 +1,48 @@
package tv.orbit.classic
import org.json.JSONObject
import java.util.Locale
internal data class CatalogueTiming(val label: String, val count: Long, val ms: Long)
internal data class CatalogueHealth(val titles: Long, val distinctById: Long, val distinctByTitle: Long, val repeatedById: Long, val withoutId: Long,
val decades: List<Pair<String, Long>>, val genres: List<Pair<String, Long>>, val tags: List<Pair<String, Long>>,
val scanMs: Long, val timings: List<CatalogueTiming>, val bytes: Long)
private val IdOrder = listOf("Imdb", "Tmdb", "Tvdb")
/** The strongest external ID, per type, because a film and a series can share a number on some sites. */
internal fun catalogueIdKey(type: String, providerIds: String): String? {
val ids = try { JSONObject(providerIds) } catch (_: Exception) { return null }
val match = IdOrder.firstNotNullOfOrNull { name -> ids.optString(name).takeIf { it.isNotBlank() }?.let { "$name:${it.lowercase(Locale.ROOT)}" } } ?: return null
return "$type|$match"
}
/** Case, punctuation and a leading article don't make a different title. The year keeps remakes apart. */
internal fun catalogueTitleKey(type: String, title: String, year: Int?): String {
val letters = title.lowercase(Locale.ROOT).map { if (it.isLetterOrDigit()) it else ' ' }.joinToString("").trim().replace(Regex(" +"), " ")
return "$type|${letters.removePrefix("the ")}|${year ?: "?"}"
}
/** Holds counts and short keys only, so a pass over the whole catalogue stays small. */
internal class CatalogueTally {
private var titles = 0L
private var withoutId = 0L
private val byId = HashMap<String, Int>()
private val byTitle = HashSet<String>()
private val decades = HashMap<Int, Long>()
private val genres = HashMap<String, Long>()
private val tags = HashMap<String, Long>()
fun add(type: String, title: String, year: Int?, genreList: String, tagList: String, providerIds: String) {
titles++
val key = catalogueIdKey(type, providerIds)
if (key == null) withoutId++ else byId.merge(key, 1, Int::plus)
byTitle += catalogueTitleKey(type, title, year)
year?.let { decades.merge(it / 10 * 10, 1L, Long::plus) }
genreList.split('|').filter { it.isNotBlank() }.forEach { genres.merge(it, 1L, Long::plus) }
tagList.split('|').filter { it.isNotBlank() }.forEach { tags.merge(it.lowercase(Locale.ROOT), 1L, Long::plus) }
}
private fun top(map: Map<String, Long>, limit: Int) = map.entries.sortedByDescending { it.value }.take(limit).map { it.key to it.value }
fun result(scanMs: Long, timings: List<CatalogueTiming>, bytes: Long) = CatalogueHealth(titles, byId.size + withoutId, byTitle.size.toLong(),
byId.values.sumOf { (it - 1).toLong() }, withoutId, decades.entries.sortedBy { it.key }.map { "${it.key}s" to it.value },
top(genres, 8), top(tags, 8), scanMs, timings, bytes)
}
@@ -64,6 +64,33 @@ internal class CatalogueStore private constructor(context: Context) : CatalogueS
total, meta(db, "finished") == "true" && total != null, count("genres != ''"), count("provider_ids != '{}'"),
count("year IS NOT NULL"), count("rating != ''"), count("premiere != ''"), count("tags != ''"), file.length())
}
/** Read-only: one pass over the saved rows and a few timed filters. It writes nothing and changes no table. */
@Synchronized fun health(checkActive: () -> Unit = {}): CatalogueHealth {
val began = System.currentTimeMillis()
val db = readableDatabase
val tally = CatalogueTally()
db.rawQuery("SELECT type, title, year, genres, tags, provider_ids FROM items", null).use { row ->
var seen = 0
while (row.moveToNext()) {
if (++seen % 2000 == 0) checkActive()
tally.add(row.getString(0), row.getString(1), if (row.isNull(2)) null else row.getInt(2), row.getString(3), row.getString(4), row.getString(5))
}
}
val scanMs = System.currentTimeMillis() - began
fun timed(label: String, sql: String, vararg args: String): CatalogueTiming {
checkActive()
val start = System.currentTimeMillis()
val count = db.rawQuery(sql, args).use { it.moveToFirst(); it.getLong(0) }
return CatalogueTiming(label, count, System.currentTimeMillis() - start)
}
val timings = listOf(
timed("Horror films, 1980s", "SELECT COUNT(*) FROM items WHERE type = 'Movie' AND year BETWEEN 1980 AND 1989 AND ('|' || genres || '|') LIKE '%|Horror|%'"),
timed("Animated series", "SELECT COUNT(*) FROM items WHERE type = 'Series' AND ('|' || genres || '|') LIKE '%|Animation|%'"),
timed("Documentaries", "SELECT COUNT(*) FROM items WHERE ('|' || genres || '|') LIKE '%|Documentary|%'"),
timed("Title search", "SELECT COUNT(*) FROM items WHERE title LIKE ?", "%Star%"),
timed("Lookup by title", "SELECT COUNT(*) FROM items WHERE type = 'Series' AND title = ?", "Fixture"))
return tally.result(scanMs, timings, file.length())
}
@Synchronized fun clear() {
close()
if (file.exists() && !SQLiteDatabase.deleteDatabase(file)) throw IllegalStateException("The catalogue could not be removed.")
@@ -1,11 +1,22 @@
package tv.orbit.classic
internal data class ChannelBranding(val presetId: String, val logo: Int, val background: Int)
internal data class ChannelBranding(val presetId: String, val displayName: String, val logo: Int, val background: Int, val bug: Int)
internal object ChannelBrands {
private val brands = mapOf(102 to ChannelBranding("orbit-after-dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background))
private val brands = mapOf(
101 to ChannelBranding("orbit-1", "Orbit 1", R.drawable.orbit_one_epg, R.drawable.orbit_one_background, R.drawable.orbit_one_bug),
102 to ChannelBranding("orbit-after-dark", "Orbit After Dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background, R.drawable.orbit_after_dark_bug),
103 to ChannelBranding("vortex", "Vortex", R.drawable.vortex_epg, R.drawable.vortex_background, R.drawable.vortex_bug))
fun forNumber(number: Int): ChannelBranding? = brands[number]
/** Saved channels keep their original names; what the viewer sees comes from the brand. */
fun displayName(channel: SampleChannel) = brands[channel.number]?.displayName ?: channel.name
}
internal fun availableSampleChannels(vararg channels: SampleChannel?): List<SampleChannel> =
channels.filterNotNull().distinctBy { it.number }.sortedBy { it.number }
/** Up and down wrap round the lineup, like a TV. A channel missing from the lineup stays put. */
internal fun channelAfter(numbers: List<Int>, current: Int, step: Int): Int {
val index = numbers.indexOf(current)
return if (index < 0 || numbers.isEmpty()) current else numbers[Math.floorMod(index + step, numbers.size)]
}
@@ -25,7 +25,7 @@ internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier:
val paper = Color(0xFFE8EDF5)
Column(modifier.border(1.dp, paper)) {
Row(Modifier.fillMaxWidth().background(paper).padding(horizontal = 12.dp, vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) {
Text(scheduled?.let { "${it.channel.number} ${it.channel.name}${if (it.clockOffsetMs != 0L) " / Transition check" else ""}" } ?: "Orbit Preview", color = Navy, fontWeight = FontWeight.Bold, fontSize = 14.sp)
Text(scheduled?.let { "${it.channel.number} ${ChannelBrands.displayName(it.channel)}${if (it.clockOffsetMs != 0L) " / Transition check" else ""}" } ?: "Orbit Preview", color = Navy, fontWeight = FontWeight.Bold, fontSize = 14.sp)
Spacer(Modifier.weight(1f))
Text(SimpleDateFormat("HH:mm EEE d MMM", Locale.UK).format(Date(now)), color = Navy, fontSize = 13.sp)
}
@@ -44,7 +44,7 @@ internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier:
horizontalArrangement = Arrangement.spacedBy(30.dp), verticalAlignment = Alignment.CenterVertically) {
Legend("▲ ▼", "Channel")
Legend("↶", "TV guide")
Legend("CC", "Subtitles")
if (scheduled != null) Legend("◄", "Last channel")
}
}
}
@@ -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.21.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.22.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -80,6 +80,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var catalogueSummary by remember { mutableStateOf<CatalogueSummary?>(null) }
var catalogueRun by remember { mutableStateOf<String?>(null) }
var reviewingCatalogue by remember { mutableStateOf(false) }
var reviewingHealth by remember { mutableStateOf(false) }
var libraryHealth by remember { mutableStateOf<CatalogueHealth?>(null) }
var lastNumber by remember { mutableStateOf<Int?>(null) }
var afterDarkChannel by remember { mutableStateOf<SampleChannel?>(null) }
var toonamiChannel by remember { mutableStateOf<SampleChannel?>(null) }
var selectedNumber by remember { mutableIntStateOf(101) }
@@ -121,7 +124,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
seriesMatches = null; episodeSamples = null; reviewingSeries = false; approvedSeries = emptySet()
darkMovies = emptyList(); approvedDark = emptySet(); reviewingDark = false; afterDarkChannel = null
metadataCoverage = null; reviewingCoverage = false; reviewingCatalogue = false; catalogueSummary = null; catalogueRun = null
metadataCoverage = null; reviewingCoverage = false; reviewingCatalogue = false; catalogueSummary = null; catalogueRun = null; reviewingHealth = false; libraryHealth = null; lastNumber = null
playback = null; session = null; sample = null; channel = null; toonamiChannel = null; selectedNumber = 101; watchingChannel = false
onChannelAvailable(false)
sessionSlot.clear()?.let { active -> EmbyCleanup.run { active.close() } }
@@ -218,14 +221,40 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if (mainGuide && liveSession != null && liveChannel != null) {
if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset,
onClose = { watchingChannel = false; channelClockOffset = 0 })
onClose = { watchingChannel = false; channelClockOffset = 0 },
onStep = { step ->
val next = channelAfter(availableChannels.map { it.number }, selectedNumber, step)
if (next != selectedNumber) { lastNumber = selectedNumber; selectedNumber = next; channelClockOffset = 0 }
},
onLast = {
val previous = lastNumber
if (previous != null && availableChannels.any { it.number == previous }) { lastNumber = selectedNumber; selectedNumber = previous; channelClockOffset = 0 }
})
SampleChannelGuide(liveChannel, modifier, onTune = { offset ->
channelClockOffset = offset; watchingChannel = true
}, onBack = onBack, otherChannels = availableChannels.filter { it.number != selectedNumber },
onSwitch = { selectedNumber = it; watchingChannel = false; channelClockOffset = 0 })
onSwitch = { lastNumber = selectedNumber; selectedNumber = it; watchingChannel = false; channelClockOffset = 0 })
return
}
if (!visible) return
if (reviewingHealth) {
LibraryHealthScreen(libraryHealth, busy, message, modifier, onRun = {
if (!busy) {
busy = true; message = null
job = scope.launch {
try {
libraryHealth = withContext(Dispatchers.IO) {
val context = coroutineContext
catalogue.health { context.ensureActive() }
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { message = "The check could not finish. Your library data hasn't changed." }
finally { busy = false }
}
}
}, onBack = { job?.cancel(); busy = false; reviewingHealth = false })
return
}
if (reviewingCatalogue) {
LaunchedEffect(Unit) {
try { catalogueSummary = withContext(Dispatchers.IO) { catalogue.summary() } }
@@ -262,7 +291,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
}
}
}, onBack = { job?.cancel(); busy = false; reviewingCatalogue = false })
}, onBack = { job?.cancel(); busy = false; reviewingCatalogue = false }, onHealth = { message = null; reviewingHealth = true })
return
}
if (reviewingCoverage) {
@@ -502,7 +531,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
})
OrbitButton("102 Orbit After Dark — choose films", Modifier.fillMaxWidth(),
enabled = !busy && session != null, onClick = { message = null; reviewingDark = true })
OrbitButton(if (seriesMatches == null) "103 Toonami — check shortlist" else "103 Toonami — review matches",
OrbitButton(if (seriesMatches == null) "103 Vortex — check shortlist" else "103 Vortex — review matches",
Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = {
if (seriesMatches != null) reviewingSeries = true else checkMoreSeries()
})
@@ -25,6 +25,8 @@ import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.PlayerView
import androidx.compose.foundation.Image
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -32,7 +34,8 @@ import kotlinx.coroutines.withContext
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null) {
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null,
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}) {
val context = LocalContext.current
var failure by remember { mutableStateOf<String?>(null) }
var tuningNumberVisible by remember { mutableStateOf(true) }
@@ -130,7 +133,10 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
if (scheduled == null && infoVisible) { if (player.isPlaying) player.pause() else player.play() }
}
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> if (scheduled == null) player.seekBack()
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_CHANNEL_UP -> if (scheduled != null) onStep(1)
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_CHANNEL_DOWN -> if (scheduled != null) onStep(-1)
KeyEvent.KEYCODE_DPAD_LEFT -> if (scheduled != null) onLast() else player.seekBack()
KeyEvent.KEYCODE_MEDIA_REWIND -> if (scheduled == null) player.seekBack()
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> if (scheduled == null) player.seekForward()
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> if (scheduled == null) { if (player.isPlaying) player.pause() else player.play() }
KeyEvent.KEYCODE_MEDIA_PLAY -> player.play()
@@ -152,6 +158,10 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
if (scheduled != null && ready && tuningNumberVisible && failure == null)
ClassicTuningNumber(scheduled.channel.number,
Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp))
// The channel's mark, off while the programme bar is up. It's drawn over the picture, never into it.
val bug = scheduled?.let { ChannelBrands.forNumber(it.channel.number)?.bug }
if (bug != null && ready && !infoVisible && failure == null)
Image(painterResource(bug), null, Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 28.dp).width(108.dp).height(36.dp), alpha = .8f)
if (infoVisible && failure == null && ready) ClassicProgrammeStrip(plan.item, playing,
Modifier.align(Alignment.BottomCenter).fillMaxWidth(.86f).padding(bottom = 26.dp), scheduled = scheduled)
if (failure != null) Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).background(Navy).padding(24.dp)) {
@@ -0,0 +1,48 @@
package tv.orbit.classic
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import java.util.Locale
private fun whole(value: Long) = String.format(Locale.UK, "%,d", value)
private fun pairs(items: List<Pair<String, Long>>) = items.joinToString(" • ") { "${it.first} ${whole(it.second)}" }
@Composable
internal fun LibraryHealthScreen(health: CatalogueHealth?, busy: Boolean, message: String?, modifier: Modifier, onRun: () -> Unit, onBack: () -> Unit) {
BackHandler(onBack = onBack)
val backFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { backFocus.requestFocus() }
Column(modifier.fillMaxWidth().background(Navy).padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Library health check", color = Yellow, fontSize = 26.sp)
Text("Read-only, using the details saved on this box. Nothing is changed and nothing goes over the network.", color = Color.White, fontSize = 15.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OrbitButton(if (busy) "Checking…" else if (health == null) "Run check" else "Run again", enabled = !busy, onClick = onRun)
OrbitButton("Back", Modifier.focusRequester(backFocus), onClick = onBack)
}
message?.let { Text(it, color = Yellow, fontSize = 15.sp, maxLines = 2) }
health?.let { h ->
Column(Modifier.fillMaxWidth().background(Panel).padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text("${whole(h.titles)} titles: ${whole(h.distinctById)} different by external ID, ${whole(h.distinctByTitle)} by title and year", color = Color.White, fontSize = 17.sp)
Text("Likely extra copies by ID: ${whole(h.repeatedById)} • no ID: ${whole(h.withoutId)}", color = Color.White, fontSize = 15.sp)
Text(pairs(h.decades), color = Muted, fontSize = 13.sp, maxLines = 2)
Text("Genres: ${pairs(h.genres)}", color = Muted, fontSize = 13.sp, maxLines = 2)
Text("Tags: ${pairs(h.tags)}", color = Muted, fontSize = 13.sp, maxLines = 2)
}
Column(Modifier.fillMaxWidth().background(Panel).padding(10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Full pass ${whole(h.scanMs)} ms • database ${whole(h.bytes / 1024)} KB", color = Color.White, fontSize = 15.sp)
h.timings.forEach { Text("${it.label}: ${whole(it.count)} found, ${whole(it.ms)} ms", color = Muted, fontSize = 14.sp) }
}
}
}
}
@@ -47,13 +47,13 @@ internal fun SampleChannelGuide(channel: SampleChannel, modifier: Modifier = Mod
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("TV Guide", color = Yellow, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
otherChannels.forEach { other -> OrbitButton("${other.number} ${other.name}", onClick = { onSwitch(other.number) }) }
otherChannels.forEach { other -> OrbitButton("${other.number} ${ChannelBrands.displayName(other)}", onClick = { onSwitch(other.number) }) }
Text(clock(now), color = Color.White, fontSize = 20.sp)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(Modifier.width(125.dp)) {
Text(channel.number.toString(), color = Yellow, fontSize = 36.sp, fontWeight = FontWeight.Bold)
if (branding != null) Image(painterResource(branding.logo), channel.name, Modifier.width(125.dp).height(40.dp))
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(125.dp).height(40.dp))
else Text(channel.name, color = Color.White, fontSize = 19.sp)
}
Column(Modifier.weight(1f)) {
@@ -87,7 +87,7 @@ internal fun SampleChannelGuide(channel: SampleChannel, modifier: Modifier = Mod
@Composable
internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long,
onClose: () -> Unit) {
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}) {
var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) }
LaunchedEffect(channel, clockOffsetMs) { while (true) { now = System.currentTimeMillis() + clockOffsetMs; delay(250) } }
val shows = channel.guide(now, 2)
@@ -118,7 +118,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
if (plan != null) {
key(current.startMs) {
EmbyPlaybackDialog(session, plan, onClose,
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs))
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast)
}
} else Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) {
@@ -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.21.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.22.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()) {
@@ -21,7 +21,7 @@ internal fun SeriesReviewScreen(matches: List<SeriesMatches>, approved: Set<Stri
LaunchedEffect(episodes) { if (episodes != null) listState.scrollToItem(0) }
BackHandler(onBack = onBack)
Column(modifier.fillMaxWidth().background(Panel).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("103 Toonami / Series check", color = Yellow, fontSize = 26.sp)
Text("103 Vortex / Series check", color = Yellow, fontSize = 26.sp)
Text("Choose up to three versions for this sample. Nothing is scheduled yet.", color = Muted, fontSize = 15.sp)
if (busy) Text("Preparing sample…", color = Yellow, fontSize = 15.sp)
message?.let { Text(it, color = Yellow, fontSize = 15.sp) }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -0,0 +1,42 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
class CatalogueStatsTest {
private fun health(vararg rows: Array<Any?>): CatalogueHealth {
val tally = CatalogueTally()
rows.forEach { tally.add(it[0] as String, it[1] as String, it[2] as Int?, it[3] as String, it[4] as String, it[5] as String) }
return tally.result(0, emptyList(), 0)
}
private fun film(title: String, year: Int?, ids: String = "{}", genres: String = "", tags: String = "") = arrayOf<Any?>("Movie", title, year, genres, tags, ids)
@Test fun copiesWithTheSameExternalIdCountAsOneTitle() {
val h = health(film("Alien", 1979, """{"Imdb":"tt0078748"}"""), film("Alien Remux", 1979, """{"Imdb":"TT0078748","Tmdb":"348"}"""), film("Aliens", 1986, """{"Imdb":"tt0090605"}"""))
assertEquals(3L, h.titles); assertEquals(2L, h.distinctById); assertEquals(1L, h.repeatedById); assertEquals(0L, h.withoutId)
}
@Test fun titlesAndYearsKeepRemakesApartAndIgnoreSpelling() {
assertEquals(catalogueTitleKey("Movie", "The Thing", 1982), catalogueTitleKey("Movie", "thing:", 1982))
assertNotEquals(catalogueTitleKey("Movie", "The Thing", 1982), catalogueTitleKey("Movie", "The Thing", 2011))
assertNotEquals(catalogueTitleKey("Movie", "Fargo", 1996), catalogueTitleKey("Series", "Fargo", 1996))
assertNotEquals(catalogueTitleKey("Movie", "Fargo", null), catalogueTitleKey("Movie", "Fargo", 1996))
}
@Test fun missingOrBrokenIdsAreCountedNotMerged() {
val h = health(film("One", 2000), film("Two", 2000, "not json"), film("Three", 2000, """{"Other":"1"}"""))
assertEquals(3L, h.withoutId); assertEquals(3L, h.distinctById); assertEquals(0L, h.repeatedById)
assertNull(catalogueIdKey("Movie", "not json"))
}
@Test fun aFilmAndASeriesCanShareANumber() {
assertNotEquals(catalogueIdKey("Movie", """{"Tmdb":"5"}"""), catalogueIdKey("Series", """{"Tmdb":"5"}"""))
}
@Test fun countsDecadesGenresAndTagsAndSortsThem() {
val h = health(film("A", 1985, genres = "Horror|Comedy", tags = "Slasher"), film("B", 1989, genres = "Horror", tags = "slasher|Cult"), film("C", 1991), film("D", null))
assertEquals(listOf("1980s" to 2L, "1990s" to 1L), h.decades)
assertEquals("Horror" to 2L, h.genres.first()); assertEquals("slasher" to 2L, h.tags.first())
}
@Test fun upAndDownWrapRoundAndAnUnknownChannelStaysPut() {
val lineup = listOf(101, 102, 103)
assertEquals(102, channelAfter(lineup, 101, 1)); assertEquals(101, channelAfter(lineup, 103, 1)); assertEquals(103, channelAfter(lineup, 101, -1))
assertEquals(999, channelAfter(lineup, 999, 1)); assertEquals(101, channelAfter(listOf(101), 101, 1))
}
}