diff --git a/VERIFICATION-0.22.0.md b/VERIFICATION-0.22.0.md new file mode 100644 index 0000000..0daed13 --- /dev/null +++ b/VERIFICATION-0.22.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fa84ff4..fec58c0 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 = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt index 4672002..2bb4894 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt @@ -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) } diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueStats.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueStats.kt new file mode 100644 index 0000000..511f450 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStats.kt @@ -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>, val genres: List>, val tags: List>, + val scanMs: Long, val timings: List, 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() + private val byTitle = HashSet() + private val decades = HashMap() + private val genres = HashMap() + private val tags = HashMap() + 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, limit: Int) = map.entries.sortedByDescending { it.value }.take(limit).map { it.key to it.value } + fun result(scanMs: Long, timings: List, 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) +} diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt index 08e0ead..e3e32a8 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt @@ -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.") diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt index d2c84df..bfb5ce4 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt @@ -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 = 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, 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)] +} diff --git a/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt b/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt index 44c8f0d..34309ef 100644 --- a/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt +++ b/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt @@ -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") } } } 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 c097b64..e596d77 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.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) 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 9deffd6..46d181b 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -80,6 +80,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var catalogueSummary by remember { mutableStateOf(null) } var catalogueRun by remember { mutableStateOf(null) } var reviewingCatalogue by remember { mutableStateOf(false) } + var reviewingHealth by remember { mutableStateOf(false) } + var libraryHealth by remember { mutableStateOf(null) } + var lastNumber by remember { mutableStateOf(null) } var afterDarkChannel by remember { mutableStateOf(null) } var toonamiChannel by remember { mutableStateOf(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() }) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index a3aaae6..176e13f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -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(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)) { diff --git a/android/app/src/main/java/tv/orbit/classic/LibraryHealthScreen.kt b/android/app/src/main/java/tv/orbit/classic/LibraryHealthScreen.kt new file mode 100644 index 0000000..6e482fc --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/LibraryHealthScreen.kt @@ -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>) = 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) } + } + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 7308780..13f7d68 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -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)) { 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 d71e104..c1e77c6 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.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()) { diff --git a/android/app/src/main/java/tv/orbit/classic/SeriesReviewScreen.kt b/android/app/src/main/java/tv/orbit/classic/SeriesReviewScreen.kt index 2bb688a..7ec22ac 100644 --- a/android/app/src/main/java/tv/orbit/classic/SeriesReviewScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/SeriesReviewScreen.kt @@ -21,7 +21,7 @@ internal fun SeriesReviewScreen(matches: List, approved: Set): 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("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)) + } +}