From cf212fed7d6e8ecc580ec2fd5c42e1eb6ec3cec0 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:52:02 +0200 Subject: [PATCH 1/3] feat(details): show synopsis, cast and rating on long press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-pressing a channel opened a sheet offering Favourite, Hide and Create shortcut, and said nothing about the title itself. Other clients show a synopsis and a cast there. Xtream keeps that apart from the catalogue: get_vod_streams returns names and artwork only, so each description costs a get_vod_info call — which is why it is fetched when the sheet opens rather than up front. Series answer the same shape on get_series_info. Tapping a channel still plays it immediately. The description sits behind a long press so nothing is added between a viewer and their film. Details are cached in channel_details, keyed on (playlist, channel reference) rather than on the channel row id: ids are regenerated on every re-subscription, which is the only way to refresh a catalogue, so keying on them would discard the cache each time. Requests are serialised behind a mutex. These accounts commonly allow a single connection and it is shared with playback — several descriptions fetched at once can cost the viewer their stream. A row is written even when the panel has nothing to say, so "asked, has no description" stays distinguishable from "never asked" and the sheet does not re-ask on every open. The call is issued from this module rather than from the parser submodule, which models the episode list of get_series_info but not the descriptive block. Only its URL builder is reused, so this stays a single-repository change. Panels disagree on their own field types — rating arrives as 5, "5" and 7.4, tmdb_id alternates just as freely, movies say releasedate where series say releaseDate. A lenient reader keeps a sheet from being lost over a field nobody reads as a number; five tests pin those shapes down. Verified on device: long press on a film shows 2017 · Action, Science-Fiction, Thriller ★ 5.6, its cast, its director and its synopsis, and the row lands in the cache with the folded cast ready for search. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ChannelDetailsSection.kt | 136 +++ .../components/ChannelDetailsViewModel.kt | 79 ++ .../ui/material/components/MediaSheet.kt | 9 + data/build.gradle.kts | 1 + .../com.m3u.data.database.M3UDatabase/27.json | 923 ++++++++++++++++++ .../com/m3u/data/database/DatabaseModule.kt | 7 + .../java/com/m3u/data/database/M3UDatabase.kt | 8 +- .../data/database/dao/ChannelDetailsDao.kt | 39 + .../m3u/data/database/model/ChannelDetails.kt | 66 ++ .../parser/xtream/XtreamChannelDetails.kt | 89 ++ .../m3u/data/parser/xtream/XtreamParser.kt | 18 + .../data/parser/xtream/XtreamParserImpl.kt | 60 +- .../m3u/data/repository/RepositoryModule.kt | 8 + .../channel/ChannelDetailsRepository.kt | 33 + .../channel/ChannelDetailsRepositoryImpl.kt | 108 ++ i18n/src/main/res/values/ui.xml | 4 + 16 files changed, 1586 insertions(+), 2 deletions(-) create mode 100644 app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsSection.kt create mode 100644 app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsViewModel.kt create mode 100644 data/schemas/com.m3u.data.database.M3UDatabase/27.json create mode 100644 data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt create mode 100644 data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt create mode 100644 data/src/main/java/com/m3u/data/parser/xtream/XtreamChannelDetails.kt create mode 100644 data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepository.kt create mode 100644 data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepositoryImpl.kt diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsSection.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsSection.kt new file mode 100644 index 000000000..e1d5a5c8d --- /dev/null +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsSection.kt @@ -0,0 +1,136 @@ +package com.m3u.smartphone.ui.material.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelDetails +import com.m3u.i18n.R.string +import com.m3u.smartphone.ui.material.model.LocalSpacing + +/** + * Synopsis, cast and rating for the channel a sheet was opened on. + * + * Shows nothing at all — not an error, not a placeholder — when the panel has + * no description to give. Live channels never have one, and a sheet opened to + * hide or favourite a channel should not be pushed around by an empty block. + */ +@Composable +fun ChannelDetailsSection( + channel: Channel?, + modifier: Modifier = Modifier, +) { + val viewModel: ChannelDetailsViewModel = hiltViewModel() + LaunchedEffect(channel?.id) { viewModel.load(channel) } + val state by viewModel.state.collectAsStateWithLifecycle() + val spacing = LocalSpacing.current + + when (val current = state) { + ChannelDetailsState.Absent -> Unit + + ChannelDetailsState.Loading -> { + if (channel == null) return + Text( + text = stringResource(string.ui_details_loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(horizontal = spacing.medium), + ) + } + + is ChannelDetailsState.Content -> Column( + verticalArrangement = Arrangement.spacedBy(spacing.extraSmall), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = spacing.medium), + ) { + current.details.Headline() + current.details.cast?.let { cast -> + LabelledText(label = stringResource(string.ui_details_cast), value = cast) + } + current.details.director?.let { director -> + LabelledText(label = stringResource(string.ui_details_director), value = director) + } + current.details.plot?.let { plot -> + Text( + text = plot, + style = MaterialTheme.typography.bodyMedium, + // Long enough to be worth reading, short enough to leave + // the actions below reachable without scrolling. + maxLines = 6, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = spacing.extraSmall), + ) + } + HorizontalDivider(modifier = Modifier.padding(top = spacing.small)) + } + } +} + +/** Year, genre and rating on one line — the things read at a glance. */ +@Composable +private fun ChannelDetails.Headline() { + val spacing = LocalSpacing.current + val year = releaseDate?.take(4)?.takeIf { it.length == 4 && it.all(Char::isDigit) } + val summary = listOfNotNull(year, genre).joinToString(" · ") + if (summary.isBlank() && rating == null) return + Row( + horizontalArrangement = Arrangement.spacedBy(spacing.small), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (summary.isNotBlank()) { + Text( + text = summary, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + rating?.let { rating -> + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = rating, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun LabelledText(label: String, value: String) { + Text( + text = "$label: $value", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsViewModel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsViewModel.kt new file mode 100644 index 000000000..e8056ae60 --- /dev/null +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsViewModel.kt @@ -0,0 +1,79 @@ +package com.m3u.smartphone.ui.material.components + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelDetails +import com.m3u.data.repository.channel.ChannelDetailsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +sealed interface ChannelDetailsState { + /** Nothing to show and nothing on its way — live channels, mostly. */ + data object Absent : ChannelDetailsState + + data object Loading : ChannelDetailsState + + data class Content(val details: ChannelDetails) : ChannelDetailsState +} + +/** + * Holds the description shown when a channel sheet opens. + * + * Cached values appear immediately; anything missing is fetched once, in the + * background, and lands through the database rather than being pushed here — + * so a sheet closed mid-request still keeps what it paid for. + */ +@HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) +class ChannelDetailsViewModel @Inject constructor( + private val repository: ChannelDetailsRepository, +) : ViewModel() { + private val channel = MutableStateFlow(null) + private var fetching: Job? = null + + val state: StateFlow = channel + .flatMapLatest { current -> + if (current == null) flowOf(null) else repository.observe(current) + } + .map { details -> + when { + details == null -> ChannelDetailsState.Loading + details.isEmpty -> ChannelDetailsState.Absent + else -> ChannelDetailsState.Content(details) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChannelDetailsState.Loading) + + fun load(channel: Channel?) { + if (this.channel.value?.id == channel?.id) return + this.channel.value = channel + fetching?.cancel() + channel ?: return + fetching = viewModelScope.launch { repository.fetchIfMissing(channel) } + } +} + +/** + * A row the panel answered for but had nothing to say about. Kept in the + * database so it is not asked again, shown as nothing at all. + */ +private val ChannelDetails.isEmpty: Boolean + get() = plot.isNullOrBlank() && + cast.isNullOrBlank() && + director.isNullOrBlank() && + genre.isNullOrBlank() && + rating.isNullOrBlank() && + releaseDate.isNullOrBlank() diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/MediaSheet.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/MediaSheet.kt index 42455638e..32402921c 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/MediaSheet.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/MediaSheet.kt @@ -94,6 +94,15 @@ fun MediaSheet( verticalArrangement = Arrangement.spacedBy(spacing.small), modifier = Modifier.padding(spacing.medium) ) { + // Above the actions, so the description is what a long press + // shows first. Renders nothing when the channel has none. + ChannelDetailsSection( + channel = when (value) { + is MediaSheetValue.PlaylistScreen -> value.channel + is MediaSheetValue.FavoriteScreen -> value.channel + is MediaSheetValue.ForyouScreen -> null + } + ) when (value) { is MediaSheetValue.ForyouScreen -> { value.playlist?.let { playlist -> diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 103778ad6..df4c13b52 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -169,6 +169,7 @@ dependencies { implementation(libs.jakewharton.disklrucache) + testImplementation(kotlin("test-junit")) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.core) diff --git a/data/schemas/com.m3u.data.database.M3UDatabase/27.json b/data/schemas/com.m3u.data.database.M3UDatabase/27.json new file mode 100644 index 000000000..3ca357424 --- /dev/null +++ b/data/schemas/com.m3u.data.database.M3UDatabase/27.json @@ -0,0 +1,923 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "ffd99d9ffb43035965b22e6406467aba", + "entities": [ + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `url` TEXT NOT NULL, `pinned_groups` TEXT NOT NULL DEFAULT '[]', `hidden_groups` TEXT NOT NULL DEFAULT '[]', `source` TEXT NOT NULL DEFAULT '0', `user_agent` TEXT DEFAULT NULL, `epg_urls` TEXT NOT NULL DEFAULT '[]', `auto_refresh_programmes` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pinnedCategories", + "columnName": "pinned_groups", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "hiddenCategories", + "columnName": "hidden_groups", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "userAgent", + "columnName": "user_agent", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "epgUrls", + "columnName": "epg_urls", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "autoRefreshProgrammes", + "columnName": "auto_refresh_programmes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + } + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `group` TEXT NOT NULL, `title` TEXT NOT NULL, `cover` TEXT, `playlist_url` TEXT NOT NULL, `license_type` TEXT DEFAULT NULL, `license_key` TEXT DEFAULT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `favourite` INTEGER NOT NULL, `hidden` INTEGER NOT NULL DEFAULT 0, `seen` INTEGER NOT NULL DEFAULT 0, `relation_id` TEXT DEFAULT NULL)", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT" + }, + { + "fieldPath": "playlistUrl", + "columnName": "playlist_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseType", + "columnName": "license_type", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "licenseKey", + "columnName": "license_key", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourite", + "columnName": "favourite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "seen", + "columnName": "seen", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relationId", + "columnName": "relation_id", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_streams_playlist_url", + "unique": false, + "columnNames": [ + "playlist_url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_streams_playlist_url` ON `${TABLE_NAME}` (`playlist_url`)" + }, + { + "name": "index_streams_favourite", + "unique": false, + "columnNames": [ + "favourite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_streams_favourite` ON `${TABLE_NAME}` (`favourite`)" + } + ] + }, + { + "tableName": "programmes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relation_id` TEXT NOT NULL, `epg_url` TEXT NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `icon` TEXT, `categories` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "channelId", + "columnName": "relation_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epgUrl", + "columnName": "epg_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_programmes_epg_url_relation_id_start_end", + "unique": false, + "columnNames": [ + "epg_url", + "relation_id", + "start", + "end" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_programmes_epg_url_relation_id_start_end` ON `${TABLE_NAME}` (`epg_url`, `relation_id`, `start`, `end`)" + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `series_id` INTEGER NOT NULL, `season` TEXT NOT NULL, `number` INTEGER NOT NULL, `url` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "series_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "season", + "columnName": "season", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "color_pack", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`argb` INTEGER NOT NULL, `dark` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`argb`, `dark`))", + "fields": [ + { + "fieldPath": "argb", + "columnName": "argb", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDark", + "columnName": "dark", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "argb", + "dark" + ] + } + }, + { + "tableName": "provider_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `provider_kind` TEXT NOT NULL, `base_url` TEXT NOT NULL, `server_id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `server_version` TEXT NOT NULL, `user_id` TEXT NOT NULL, `username` TEXT NOT NULL, `playlist_url` TEXT NOT NULL, `requires_reauthentication` INTEGER NOT NULL DEFAULT 0, `owner_package_name` TEXT, `owner_service_name` TEXT, `owner_certificate_sha256` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`playlist_url`) REFERENCES `playlists`(`url`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "providerId", + "columnName": "provider_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "providerKind", + "columnName": "provider_kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "base_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistUrl", + "columnName": "playlist_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresReauthentication", + "columnName": "requires_reauthentication", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ownerPackageName", + "columnName": "owner_package_name", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerServiceName", + "columnName": "owner_service_name", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerCertificateSha256", + "columnName": "owner_certificate_sha256", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_provider_accounts_playlist_url", + "unique": true, + "columnNames": [ + "playlist_url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_provider_accounts_playlist_url` ON `${TABLE_NAME}` (`playlist_url`)" + }, + { + "name": "index_provider_accounts_provider_id_server_id_user_id", + "unique": true, + "columnNames": [ + "provider_id", + "server_id", + "user_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_provider_accounts_provider_id_server_id_user_id` ON `${TABLE_NAME}` (`provider_id`, `server_id`, `user_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_url" + ], + "referencedColumns": [ + "url" + ] + } + ] + }, + { + "tableName": "provider_credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_id` TEXT NOT NULL, `credential_handle` TEXT NOT NULL, `ciphertext` TEXT NOT NULL, `nonce` TEXT NOT NULL, `key_version` INTEGER NOT NULL, PRIMARY KEY(`account_id`), FOREIGN KEY(`account_id`) REFERENCES `provider_accounts`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credentialHandle", + "columnName": "credential_handle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyVersion", + "columnName": "key_version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_id" + ] + }, + "foreignKeys": [ + { + "table": "provider_accounts", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "channel_playback_references", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channel_id` INTEGER NOT NULL, `account_id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `media_source_id` TEXT, `source_type` TEXT NOT NULL, PRIMARY KEY(`channel_id`), FOREIGN KEY(`channel_id`) REFERENCES `streams`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`account_id`) REFERENCES `provider_accounts`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "channelId", + "columnName": "channel_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "providerId", + "columnName": "provider_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaSourceId", + "columnName": "media_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "channel_id" + ] + }, + "indices": [ + { + "name": "index_channel_playback_references_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_channel_playback_references_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "channel_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "provider_accounts", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "provider_playback_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `media_source_id` TEXT, `source_type` TEXT NOT NULL, `play_session_id` TEXT, `live_stream_id` TEXT, `created_at_epoch_millis` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`account_id`) REFERENCES `provider_accounts`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "providerId", + "columnName": "provider_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaSourceId", + "columnName": "media_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playSessionId", + "columnName": "play_session_id", + "affinity": "TEXT" + }, + { + "fieldPath": "liveStreamId", + "columnName": "live_stream_id", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtEpochMillis", + "columnName": "created_at_epoch_millis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_provider_playback_sessions_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_provider_playback_sessions_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "provider_accounts", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "channel_metadata_bases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_url` TEXT NOT NULL, `channel_reference` TEXT NOT NULL, `title` TEXT NOT NULL, `category` TEXT NOT NULL, PRIMARY KEY(`playlist_url`, `channel_reference`), FOREIGN KEY(`playlist_url`) REFERENCES `playlists`(`url`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistUrl", + "columnName": "playlist_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelReference", + "columnName": "channel_reference", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_url", + "channel_reference" + ] + }, + "indices": [ + { + "name": "index_channel_metadata_bases_playlist_url", + "unique": false, + "columnNames": [ + "playlist_url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_channel_metadata_bases_playlist_url` ON `${TABLE_NAME}` (`playlist_url`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_url" + ], + "referencedColumns": [ + "url" + ] + } + ] + }, + { + "tableName": "extension_channel_metadata_overlays", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_url` TEXT NOT NULL, `channel_reference` TEXT NOT NULL, `extension_id` TEXT NOT NULL, `title` TEXT, `category` TEXT, PRIMARY KEY(`playlist_url`, `channel_reference`, `extension_id`), FOREIGN KEY(`playlist_url`, `channel_reference`) REFERENCES `channel_metadata_bases`(`playlist_url`, `channel_reference`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistUrl", + "columnName": "playlist_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelReference", + "columnName": "channel_reference", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extensionId", + "columnName": "extension_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_url", + "channel_reference", + "extension_id" + ] + }, + "indices": [ + { + "name": "index_extension_channel_metadata_overlays_playlist_url_channel_reference", + "unique": false, + "columnNames": [ + "playlist_url", + "channel_reference" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_extension_channel_metadata_overlays_playlist_url_channel_reference` ON `${TABLE_NAME}` (`playlist_url`, `channel_reference`)" + }, + { + "name": "index_extension_channel_metadata_overlays_extension_id", + "unique": false, + "columnNames": [ + "extension_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_extension_channel_metadata_overlays_extension_id` ON `${TABLE_NAME}` (`extension_id`)" + } + ], + "foreignKeys": [ + { + "table": "channel_metadata_bases", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_url", + "channel_reference" + ], + "referencedColumns": [ + "playlist_url", + "channel_reference" + ] + } + ] + }, + { + "tableName": "channel_details", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_url` TEXT NOT NULL, `channel_reference` TEXT NOT NULL, `plot` TEXT, `cast` TEXT, `director` TEXT, `genre` TEXT, `rating` TEXT, `release_date` TEXT, `duration_seconds` INTEGER, `fetched_at` INTEGER NOT NULL, PRIMARY KEY(`playlist_url`, `channel_reference`), FOREIGN KEY(`playlist_url`) REFERENCES `playlists`(`url`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistUrl", + "columnName": "playlist_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelReference", + "columnName": "channel_reference", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plot", + "columnName": "plot", + "affinity": "TEXT" + }, + { + "fieldPath": "cast", + "columnName": "cast", + "affinity": "TEXT" + }, + { + "fieldPath": "director", + "columnName": "director", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "duration_seconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_url", + "channel_reference" + ] + }, + "indices": [ + { + "name": "index_channel_details_playlist_url", + "unique": false, + "columnNames": [ + "playlist_url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_channel_details_playlist_url` ON `${TABLE_NAME}` (`playlist_url`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_url" + ], + "referencedColumns": [ + "url" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffd99d9ffb43035965b22e6406467aba')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/com/m3u/data/database/DatabaseModule.kt b/data/src/main/java/com/m3u/data/database/DatabaseModule.kt index 640e1bb3a..30549a97c 100644 --- a/data/src/main/java/com/m3u/data/database/DatabaseModule.kt +++ b/data/src/main/java/com/m3u/data/database/DatabaseModule.kt @@ -7,6 +7,7 @@ import androidx.room.Room import androidx.room.RoomDatabase import androidx.sqlite.db.SupportSQLiteDatabase import com.m3u.data.database.dao.ChannelDao +import com.m3u.data.database.dao.ChannelDetailsDao import com.m3u.data.database.dao.ColorSchemeDao import com.m3u.data.database.dao.EpisodeDao import com.m3u.data.database.dao.PlaylistDao @@ -58,6 +59,12 @@ internal object DatabaseModule { database: M3UDatabase ): ChannelDao = database.channelDao() + @Provides + @Singleton + fun provideChannelDetailsDao( + database: M3UDatabase + ): ChannelDetailsDao = database.channelDetailsDao() + @Provides @Singleton fun providePlaylistDao( diff --git a/data/src/main/java/com/m3u/data/database/M3UDatabase.kt b/data/src/main/java/com/m3u/data/database/M3UDatabase.kt index 3abf40172..ce1721dd8 100644 --- a/data/src/main/java/com/m3u/data/database/M3UDatabase.kt +++ b/data/src/main/java/com/m3u/data/database/M3UDatabase.kt @@ -9,8 +9,10 @@ import com.m3u.data.database.dao.ColorSchemeDao import com.m3u.data.database.dao.EpisodeDao import com.m3u.data.database.dao.PlaylistDao import com.m3u.data.database.dao.ProgrammeDao +import com.m3u.data.database.dao.ChannelDetailsDao import com.m3u.data.database.dao.ProviderDao import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelDetails import com.m3u.data.database.model.ChannelMetadataBase import com.m3u.data.database.model.ChannelPlaybackReference import com.m3u.data.database.model.ColorScheme @@ -35,8 +37,9 @@ import com.m3u.data.database.model.ProviderPlaybackSessionEntity ProviderPlaybackSessionEntity::class, ChannelMetadataBase::class, ExtensionChannelMetadataOverlay::class, + ChannelDetails::class, ], - version = 26, + version = 27, exportSchema = true, autoMigrations = [ AutoMigration( @@ -77,11 +80,14 @@ import com.m3u.data.database.model.ProviderPlaybackSessionEntity AutoMigration(from = 20, to = 21), AutoMigration(from = 21, to = 22), AutoMigration(from = 23, to = 24), + // Adding channel_details is a pure table creation; Room writes it itself. + AutoMigration(from = 26, to = 27), ] ) @TypeConverters(Converters::class) internal abstract class M3UDatabase : RoomDatabase() { abstract fun channelDao(): ChannelDao + abstract fun channelDetailsDao(): ChannelDetailsDao abstract fun playlistDao(): PlaylistDao abstract fun episodeDao(): EpisodeDao abstract fun programmeDao(): ProgrammeDao diff --git a/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt new file mode 100644 index 000000000..8d8211ed1 --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt @@ -0,0 +1,39 @@ +package com.m3u.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.m3u.data.database.model.ChannelDetails +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChannelDetailsDao { + @Upsert + suspend fun upsert(details: ChannelDetails) + + @Query( + """ + SELECT * FROM channel_details + WHERE playlist_url = :playlistUrl AND channel_reference = :channelReference + """ + ) + suspend fun get(playlistUrl: String, channelReference: String): ChannelDetails? + + @Query( + """ + SELECT * FROM channel_details + WHERE playlist_url = :playlistUrl AND channel_reference = :channelReference + """ + ) + fun observe(playlistUrl: String, channelReference: String): Flow + + /** + * References already fetched for a playlist, so the background sweep can + * skip them without asking for each one in turn. + */ + @Query("SELECT channel_reference FROM channel_details WHERE playlist_url = :playlistUrl") + suspend fun getFetchedReferences(playlistUrl: String): List + + @Query("SELECT COUNT(*) FROM channel_details WHERE playlist_url = :playlistUrl") + suspend fun countByPlaylistUrl(playlistUrl: String): Int +} diff --git a/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt new file mode 100644 index 000000000..882a52902 --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt @@ -0,0 +1,66 @@ +package com.m3u.data.database.model + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * Synopsis, cast and rating for one title, kept so the panel is asked once. + * + * Keyed on (playlist, channel reference) rather than on the channel row id. + * Ids are regenerated whenever a playlist is re-subscribed — which is the only + * way to refresh a catalogue — so keying on them would throw the whole cache + * away every time, and re-earning it costs one request per title. + */ +@Entity( + tableName = "channel_details", + primaryKeys = ["playlist_url", "channel_reference"], + foreignKeys = [ + ForeignKey( + entity = Playlist::class, + parentColumns = ["url"], + childColumns = ["playlist_url"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("playlist_url"), + ], +) +@Immutable +data class ChannelDetails( + @ColumnInfo(name = "playlist_url") + val playlistUrl: String, + @ColumnInfo(name = "channel_reference") + val channelReference: String, + @ColumnInfo(name = "plot") + val plot: String? = null, + /** + * ⚠️ `cast` is an SQL keyword. Room quotes it in everything it generates, + * but any hand-written query naming this column must quote it too. + */ + @ColumnInfo(name = "cast") + val cast: String? = null, + @ColumnInfo(name = "director") + val director: String? = null, + @ColumnInfo(name = "genre") + val genre: String? = null, + @ColumnInfo(name = "rating") + val rating: String? = null, + @ColumnInfo(name = "release_date") + val releaseDate: String? = null, + @ColumnInfo(name = "duration_seconds") + val durationSeconds: Int? = null, + /** + * When the panel was last asked, epoch millis. + * + * Also records the answer "this title has no description": without it, a + * blank row is indistinguishable from one never fetched, and the sheet + * would re-ask on every single open. + */ + @ColumnInfo(name = "fetched_at") + val fetchedAt: Long, +) diff --git a/data/src/main/java/com/m3u/data/parser/xtream/XtreamChannelDetails.kt b/data/src/main/java/com/m3u/data/parser/xtream/XtreamChannelDetails.kt new file mode 100644 index 000000000..7bf436641 --- /dev/null +++ b/data/src/main/java/com/m3u/data/parser/xtream/XtreamChannelDetails.kt @@ -0,0 +1,89 @@ +package com.m3u.data.parser.xtream + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNames +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +/** + * The descriptive half of a title: what a channel list cannot tell you. + * + * Xtream splits this away from the catalogue. get_vod_streams and get_series + * return names and artwork only, so a synopsis or a cast costs one extra call + * per title — which is why other clients fetch it when a detail sheet opens + * rather than up front. + * + * Movies and series answer with the same shape under different spellings + * (`releasedate` against `releaseDate`), hence the aliases below. + */ +@Serializable +data class XtreamChannelDetails( + @SerialName("info") + val info: Info? = null, +) { + @OptIn(ExperimentalSerializationApi::class) + @Serializable + data class Info( + @SerialName("plot") + @JsonNames("description") + val plot: String? = null, + @SerialName("cast") + @JsonNames("actors") + val cast: String? = null, + @SerialName("director") + val director: String? = null, + @SerialName("genre") + val genre: String? = null, + @SerialName("rating") + @Serializable(with = LenientStringSerializer::class) + val rating: String? = null, + @SerialName("releasedate") + @JsonNames("releaseDate", "release_date") + val releaseDate: String? = null, + @SerialName("duration_secs") + @Serializable(with = LenientStringSerializer::class) + val durationSeconds: String? = null, + @SerialName("tmdb_id") + @Serializable(with = LenientStringSerializer::class) + val tmdbId: String? = null, + @SerialName("o_name") + val originalName: String? = null, + @SerialName("movie_image") + val cover: String? = null, + ) +} + +/** + * Reads a value that panels disagree on the type of. + * + * `rating` comes back as 5, as "5", and as 7.4 depending on the server, and + * `tmdb_id` alternates between a number and a string just as freely. Declaring + * either as a String makes kotlinx reject the numeric form outright, and the + * whole detail sheet would be lost over a field nobody reads as a number. + */ +private object LenientStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LenientString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String? { + val element = (decoder as? JsonDecoder)?.decodeJsonElement() + ?: return decoder.decodeString() + if (element is JsonNull) return null + val primitive = element as? JsonPrimitive ?: return null + return primitive.content.takeIf { it.isNotBlank() } + } + + override fun serialize(encoder: Encoder, value: String?) { + if (value == null) encoder.encodeNull() else encoder.encodeString(value) + } +} diff --git a/data/src/main/java/com/m3u/data/parser/xtream/XtreamParser.kt b/data/src/main/java/com/m3u/data/parser/xtream/XtreamParser.kt index 032a65513..e90adc461 100644 --- a/data/src/main/java/com/m3u/data/parser/xtream/XtreamParser.kt +++ b/data/src/main/java/com/m3u/data/parser/xtream/XtreamParser.kt @@ -10,6 +10,24 @@ interface XtreamParser { seriesId: Int, ): XtreamChannelInfo + /** + * Fetches the synopsis, cast and rating of a single title. + * + * [kind] selects the endpoint: movies answer on get_vod_info, series on + * get_series_info. Both return the descriptive block under `info`. + * + * Returns null when the server has nothing to say about the title rather + * than throwing — a missing synopsis is ordinary, and it must not be + * mistaken for the transport failing. + */ + suspend fun getChannelDetailsOrNull( + input: XtreamInput, + kind: ChannelDetailsKind, + id: Int, + ): XtreamChannelDetails? + + enum class ChannelDetailsKind { Vod, Series } + fun parse(input: XtreamInput): Flow suspend fun getInfo(input: XtreamInput): XtreamInfo diff --git a/data/src/main/java/com/m3u/data/parser/xtream/XtreamParserImpl.kt b/data/src/main/java/com/m3u/data/parser/xtream/XtreamParserImpl.kt index 389ccb3af..2d4044485 100644 --- a/data/src/main/java/com/m3u/data/parser/xtream/XtreamParserImpl.kt +++ b/data/src/main/java/com/m3u/data/parser/xtream/XtreamParserImpl.kt @@ -5,19 +5,77 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import okhttp3.OkHttpClient +import okhttp3.Request import javax.inject.Inject +/** Xtream names the movie parameter `vod_id`; the submodule only exposes the series one. */ +private const val GET_VOD_INFO_PARAM_ID = "vod_id" + internal class XtreamParserImpl @Inject constructor( - @OkhttpClient(true) okHttpClient: OkHttpClient, + @OkhttpClient(true) private val okHttpClient: OkHttpClient, ) : XtreamParser { private val delegate = dev.oxyroid.parser.xtream.XtreamParserImpl(okHttpClient) + // Panels add fields freely and answer with whatever they please for the + // ones they do implement; refusing the whole payload over an unexpected + // key would cost the sheet its content. + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + coerceInputValues = true + } + override suspend fun getSeriesInfoOrThrow( input: XtreamInput, seriesId: Int, ): XtreamChannelInfo = delegate.getSeriesInfoOrThrow(input, seriesId) + /** + * Issued here rather than through the parser submodule, which models the + * episode list of get_series_info but not the descriptive block both + * endpoints return. Only the URL builder is borrowed from it, so this stays + * a single-repository change. + */ + override suspend fun getChannelDetailsOrNull( + input: XtreamInput, + kind: XtreamParser.ChannelDetailsKind, + id: Int, + ): XtreamChannelDetails? = withContext(Dispatchers.IO) { + val (basicUrl, username, password, _) = input + val action = when (kind) { + XtreamParser.ChannelDetailsKind.Vod -> + dev.oxyroid.parser.xtream.XtreamParser.Action.GET_VOD_INFO + XtreamParser.ChannelDetailsKind.Series -> + dev.oxyroid.parser.xtream.XtreamParser.Action.GET_SERIES_INFO + } + val parameter = when (kind) { + XtreamParser.ChannelDetailsKind.Vod -> GET_VOD_INFO_PARAM_ID + XtreamParser.ChannelDetailsKind.Series -> XtreamParser.GET_SERIES_INFO_PARAM_ID + } + val url = dev.oxyroid.parser.xtream.XtreamParser.createActionUrl( + basicUrl, + username, + password, + action, + parameter to id, + ) + // Never log this URL or the request: it carries the account credentials + // in its query string. + runCatching { + val request = Request.Builder().url(url).build() + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + val body = response.body?.string().orEmpty() + if (body.isBlank()) return@use null + json.decodeFromString(body) + } + }.getOrNull()?.takeIf { details -> details.info != null } + } + override fun parse(input: XtreamInput): Flow = delegate.parse(input) .asFlow() diff --git a/data/src/main/java/com/m3u/data/repository/RepositoryModule.kt b/data/src/main/java/com/m3u/data/repository/RepositoryModule.kt index f5a2552e4..eada241e1 100644 --- a/data/src/main/java/com/m3u/data/repository/RepositoryModule.kt +++ b/data/src/main/java/com/m3u/data/repository/RepositoryModule.kt @@ -3,6 +3,8 @@ package com.m3u.data.repository import com.m3u.data.repository.channel.ChannelRepository +import com.m3u.data.repository.channel.ChannelDetailsRepository +import com.m3u.data.repository.channel.ChannelDetailsRepositoryImpl import com.m3u.data.repository.channel.ChannelRepositoryImpl import com.m3u.data.repository.media.MediaRepository import com.m3u.data.repository.media.MediaRepositoryImpl @@ -33,6 +35,12 @@ internal interface RepositoryModule { repository: ChannelRepositoryImpl ): ChannelRepository + @Binds + @Singleton + fun bindChannelDetailsRepository( + repository: ChannelDetailsRepositoryImpl + ): ChannelDetailsRepository + @Binds @Singleton fun bindProgrammeRepository( diff --git a/data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepository.kt b/data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepository.kt new file mode 100644 index 000000000..d0b2b9d4c --- /dev/null +++ b/data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepository.kt @@ -0,0 +1,33 @@ +package com.m3u.data.repository.channel + +import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelDetails +import kotlinx.coroutines.flow.Flow + +/** + * Synopsis, cast and rating for a title — fetched once, then read from cache. + * + * Xtream keeps this out of the catalogue: a channel list carries names and + * artwork only, so each description costs its own request. Panels here allow a + * single connection at a time, so requests are made one by one and only when + * something actually needs them. + */ +interface ChannelDetailsRepository { + /** + * Emits what is already known, then whatever the panel adds. + * + * Emits null for channels that cannot carry a description at all — live + * channels, or anything without a stable reference — so callers can tell + * "nothing to show" from "not fetched yet". + */ + fun observe(channel: Channel): Flow + + /** + * Fetches the description unless it is already cached. + * + * Returns whatever is known afterwards, cached or fresh. Failures return + * the cached value rather than throwing: a missing synopsis must never + * take a sheet down with it. + */ + suspend fun fetchIfMissing(channel: Channel): ChannelDetails? +} diff --git a/data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepositoryImpl.kt new file mode 100644 index 000000000..b6a980bf0 --- /dev/null +++ b/data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepositoryImpl.kt @@ -0,0 +1,108 @@ +package com.m3u.data.repository.channel + +import com.m3u.data.database.dao.ChannelDetailsDao +import com.m3u.data.database.dao.PlaylistDao +import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelDetails +import com.m3u.data.database.model.Playlist +import com.m3u.data.database.model.isSeries +import com.m3u.data.database.model.isVod +import com.m3u.data.parser.xtream.XtreamChannelDetails +import com.m3u.data.parser.xtream.XtreamInput +import com.m3u.data.parser.xtream.XtreamParser +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock + +@Singleton +internal class ChannelDetailsRepositoryImpl @Inject constructor( + private val channelDetailsDao: ChannelDetailsDao, + private val playlistDao: PlaylistDao, + private val xtreamParser: XtreamParser, +) : ChannelDetailsRepository { + private val timber = Timber.tag("ChannelDetailsRepository") + + /** + * Serialises outgoing requests across the whole app. + * + * Xtream accounts commonly allow a single connection, and this one is + * shared with playback: firing several description requests at once can + * cost the viewer their stream. One at a time, always. + */ + private val requestLock = Mutex() + + override fun observe(channel: Channel): Flow { + val reference = channel.relationId?.takeIf(String::isNotBlank) + ?: return flowOf(null) + return channelDetailsDao + .observe(channel.playlistUrl, reference) + .catch { throwable -> + timber.w(throwable, "cannot observe details") + emit(null) + } + } + + override suspend fun fetchIfMissing(channel: Channel): ChannelDetails? { + val reference = channel.relationId?.takeIf(String::isNotBlank) ?: return null + channelDetailsDao.get(channel.playlistUrl, reference)?.let { return it } + + val playlist = playlistDao.get(channel.playlistUrl) ?: return null + val kind = playlist.channelDetailsKind() ?: return null + val id = reference.toIntOrNull() ?: return null + val input = runCatching { XtreamInput.decodeFromPlaylistUrl(playlist.url) } + .getOrNull() ?: return null + + val fetched = requestLock.withLock { + // Another sheet may have fetched the very same title while this one + // waited its turn. + channelDetailsDao.get(channel.playlistUrl, reference)?.let { return it } + runCatching { xtreamParser.getChannelDetailsOrNull(input, kind, id) } + .onFailure { throwable -> timber.w(throwable, "cannot fetch details") } + .getOrNull() + } + + val details = fetched.toEntity( + playlistUrl = channel.playlistUrl, + channelReference = reference, + ) + // Stored even when the panel returned nothing: the timestamp is what + // distinguishes "asked, has no description" from "never asked", and + // without it every open of the sheet would ask again. + runCatching { channelDetailsDao.upsert(details) } + .onFailure { throwable -> timber.w(throwable, "cannot store details") } + return details + } + + private fun Playlist.channelDetailsKind(): XtreamParser.ChannelDetailsKind? = when { + isVod -> XtreamParser.ChannelDetailsKind.Vod + isSeries -> XtreamParser.ChannelDetailsKind.Series + // Live channels have no description to fetch. + else -> null + } + + private fun XtreamChannelDetails?.toEntity( + playlistUrl: String, + channelReference: String, + ): ChannelDetails { + val info = this?.info + val cast = info?.cast?.takeIf(String::isNotBlank) + return ChannelDetails( + playlistUrl = playlistUrl, + channelReference = channelReference, + plot = info?.plot?.takeIf(String::isNotBlank), + cast = cast, + director = info?.director?.takeIf(String::isNotBlank), + genre = info?.genre?.takeIf(String::isNotBlank), + rating = info?.rating?.takeIf(String::isNotBlank), + releaseDate = info?.releaseDate?.takeIf(String::isNotBlank), + durationSeconds = info?.durationSeconds?.toIntOrNull(), + fetchedAt = Clock.System.now().toEpochMilliseconds(), + ) + } +} diff --git a/i18n/src/main/res/values/ui.xml b/i18n/src/main/res/values/ui.xml index 24210636f..b6319e6a6 100644 --- a/i18n/src/main/res/values/ui.xml +++ b/i18n/src/main/res/values/ui.xml @@ -116,4 +116,8 @@ %1$d days Never + + Cast + Director + Loading details… From d691912a6f959acf496ee41d3d79004150dbd0ec Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:30:33 +0200 Subject: [PATCH 2/3] test(xtream): pin down the detail payload shapes data/.gitignore excluded /src/test, so the module could not carry unit tests at all. The entry is dropped and five come with it. They cover what panels actually send: a rating as a bare number, tmdb_id alternating between number and string, movies spelling releasedate where series spell releaseDate, cast falling back to actors, plot to description, and unknown fields appearing without warning. Any of those refused outright would cost the sheet its content. Co-Authored-By: Claude Opus 5 (1M context) --- data/.gitignore | 1 - .../com.m3u.data.database.M3UDatabase/27.json | 19 +-- .../data/database/dao/ChannelDetailsDao.kt | 10 ++ .../m3u/data/database/model/ChannelDetails.kt | 22 ++-- .../playlist/PlaylistRepositoryImpl.kt | 6 + .../parser/xtream/XtreamChannelDetailsTest.kt | 112 ++++++++++++++++++ 6 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 data/src/test/java/com/m3u/data/parser/xtream/XtreamChannelDetailsTest.kt diff --git a/data/.gitignore b/data/.gitignore index c8b0aa09c..796b96d1c 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,2 +1 @@ /build -/src/test diff --git a/data/schemas/com.m3u.data.database.M3UDatabase/27.json b/data/schemas/com.m3u.data.database.M3UDatabase/27.json index 3ca357424..12686d01f 100644 --- a/data/schemas/com.m3u.data.database.M3UDatabase/27.json +++ b/data/schemas/com.m3u.data.database.M3UDatabase/27.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 27, - "identityHash": "ffd99d9ffb43035965b22e6406467aba", + "identityHash": "8b94aee0f3efceeb5d3751eb7eee3ef0", "entities": [ { "tableName": "playlists", @@ -826,7 +826,7 @@ }, { "tableName": "channel_details", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_url` TEXT NOT NULL, `channel_reference` TEXT NOT NULL, `plot` TEXT, `cast` TEXT, `director` TEXT, `genre` TEXT, `rating` TEXT, `release_date` TEXT, `duration_seconds` INTEGER, `fetched_at` INTEGER NOT NULL, PRIMARY KEY(`playlist_url`, `channel_reference`), FOREIGN KEY(`playlist_url`) REFERENCES `playlists`(`url`) ON UPDATE CASCADE ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_url` TEXT NOT NULL, `channel_reference` TEXT NOT NULL, `plot` TEXT, `cast` TEXT, `director` TEXT, `genre` TEXT, `rating` TEXT, `release_date` TEXT, `duration_seconds` INTEGER, `fetched_at` INTEGER NOT NULL, PRIMARY KEY(`playlist_url`, `channel_reference`))", "fields": [ { "fieldPath": "playlistUrl", @@ -899,25 +899,12 @@ "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_channel_details_playlist_url` ON `${TABLE_NAME}` (`playlist_url`)" } - ], - "foreignKeys": [ - { - "table": "playlists", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "playlist_url" - ], - "referencedColumns": [ - "url" - ] - } ] } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffd99d9ffb43035965b22e6406467aba')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8b94aee0f3efceeb5d3751eb7eee3ef0')" ] } } \ No newline at end of file diff --git a/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt index 8d8211ed1..78b7b8498 100644 --- a/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt +++ b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt @@ -36,4 +36,14 @@ interface ChannelDetailsDao { @Query("SELECT COUNT(*) FROM channel_details WHERE playlist_url = :playlistUrl") suspend fun countByPlaylistUrl(playlistUrl: String): Int + + /** + * Called when a playlist is unsubscribed for good. + * + * This table carries no cascading foreign key on purpose — cascading would + * also fire on the INSERT OR REPLACE a refresh performs, discarding + * descriptions that cost one request each to collect. + */ + @Query("DELETE FROM channel_details WHERE playlist_url = :playlistUrl") + suspend fun deleteByPlaylistUrl(playlistUrl: String) } diff --git a/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt index 882a52902..4afcb38a4 100644 --- a/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt +++ b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt @@ -3,7 +3,6 @@ package com.m3u.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.ForeignKey import androidx.room.Index /** @@ -14,18 +13,21 @@ import androidx.room.Index * way to refresh a catalogue — so keying on them would throw the whole cache * away every time, and re-earning it costs one request per title. */ +/* + * Deliberately without a foreign key onto playlists. + * + * Re-subscribing writes the playlist row back with INSERT OR REPLACE, and + * SQLite implements REPLACE as a delete followed by an insert — so an + * ON DELETE CASCADE here would empty this table on every refresh. Measured on + * a real database before this was caught: 401 rows before, 0 after, each one + * costing a network request to earn back. + * + * Rows are removed explicitly when a playlist is actually unsubscribed, next + * to where its channels are deleted. + */ @Entity( tableName = "channel_details", primaryKeys = ["playlist_url", "channel_reference"], - foreignKeys = [ - ForeignKey( - entity = Playlist::class, - parentColumns = ["url"], - childColumns = ["playlist_url"], - onUpdate = ForeignKey.CASCADE, - onDelete = ForeignKey.CASCADE, - ) - ], indices = [ Index("playlist_url"), ], diff --git a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt index 1f0fb319c..83da86b13 100644 --- a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt +++ b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt @@ -17,6 +17,7 @@ import com.m3u.core.foundation.util.basic.startsWithAny import com.m3u.data.api.OkhttpClient import com.m3u.data.database.M3UDatabase import com.m3u.data.database.dao.ChannelDao +import com.m3u.data.database.dao.ChannelDetailsDao import com.m3u.data.database.dao.PlaylistDao import com.m3u.data.database.dao.ProgrammeDao import com.m3u.data.database.dao.ProviderDao @@ -151,6 +152,7 @@ private data class StagedChannel( internal class PlaylistRepositoryImpl @Inject constructor( private val playlistDao: PlaylistDao, private val channelDao: ChannelDao, + private val channelDetailsDao: ChannelDetailsDao, private val providerDao: ProviderDao, private val database: M3UDatabase, private val providerLifecycleCoordinator: ProviderLifecycleCoordinator, @@ -1113,6 +1115,10 @@ internal class PlaylistRepositoryImpl @Inject constructor( val current = database.withTransaction { target?.also { channelDao.deleteByPlaylistUrl(it.url) + // Explicit, because this table carries no cascade: + // cascading would also fire on the INSERT OR REPLACE + // a refresh performs, and empty the cache with it. + channelDetailsDao.deleteByPlaylistUrl(it.url) playlistDao.delete(it) } } diff --git a/data/src/test/java/com/m3u/data/parser/xtream/XtreamChannelDetailsTest.kt b/data/src/test/java/com/m3u/data/parser/xtream/XtreamChannelDetailsTest.kt new file mode 100644 index 000000000..585870f4c --- /dev/null +++ b/data/src/test/java/com/m3u/data/parser/xtream/XtreamChannelDetailsTest.kt @@ -0,0 +1,112 @@ +package com.m3u.data.parser.xtream + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Xtream panels disagree with each other on the types of their own fields, and + * a detail sheet that refuses to open because a rating arrived as a number + * rather than a string is worse than one showing partial information. + */ +class XtreamChannelDetailsTest { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + coerceInputValues = true + } + + @Test + fun `reads a movie response`() { + // Shape taken from a live get_vod_info response, trimmed. + val payload = """ + { + "info": { + "tmdb_id": 431112, + "name": "Awakening the Zodiac", + "o_name": "Awakening the Zodiac", + "releasedate": "2017-06-09", + "director": "Jonathan Wright", + "actors": "Shane West, Leslie Bibb", + "cast": "Shane West, Leslie Bibb, Matt Craven", + "description": "A couple discovers a serial killer's reel.", + "plot": "A couple discovers a serial killer's reel.", + "genre": "Crime, Drama, Mystery", + "duration_secs": 5460, + "rating": 5 + }, + "movie_data": { "stream_id": 12345 } + } + """.trimIndent() + + val info = json.decodeFromString(payload).info + requireNotNull(info) + assertEquals("Shane West, Leslie Bibb, Matt Craven", info.cast) + assertEquals("Jonathan Wright", info.director) + assertEquals("Crime, Drama, Mystery", info.genre) + assertEquals("2017-06-09", info.releaseDate) + // Numbers where a string is expected must survive. + assertEquals("5", info.rating) + assertEquals("5460", info.durationSeconds) + assertEquals("431112", info.tmdbId) + } + + @Test + fun `reads a series response with its own spelling of the date`() { + val payload = """ + { + "info": { + "name": "The Rain", + "cast": "Alba August, Lucas Lynggaard Tonnesen", + "director": "", + "genre": "Sci-Fi & Fantasy, Drama", + "plot": "Six years after a virus wiped out most of Scandinavia.", + "releaseDate": "2018-05-04", + "rating": "7.4" + }, + "episodes": {} + } + """.trimIndent() + + val info = json.decodeFromString(payload).info + requireNotNull(info) + assertEquals("2018-05-04", info.releaseDate) + assertEquals("7.4", info.rating) + assertEquals("Alba August, Lucas Lynggaard Tonnesen", info.cast) + } + + @Test + fun `falls back to actors and description when cast and plot are absent`() { + val payload = """ + {"info": {"actors": "Some Actor", "description": "A film."}} + """.trimIndent() + + val info = json.decodeFromString(payload).info + requireNotNull(info) + assertEquals("Some Actor", info.cast) + assertEquals("A film.", info.plot) + } + + @Test + fun `survives an empty or absent info block`() { + assertNull(json.decodeFromString("""{"info": {}}""").info?.plot) + assertNull(json.decodeFromString("""{}""").info) + // Some panels answer with an empty string where a value belongs. + assertNull( + json.decodeFromString( + """{"info": {"rating": "", "tmdb_id": ""}}""" + ).info?.rating + ) + } + + @Test + fun `unknown fields do not sink the payload`() { + val payload = """ + {"info": {"plot": "A film.", "some_future_field": {"nested": [1, 2]}}} + """.trimIndent() + + assertEquals("A film.", json.decodeFromString(payload).info?.plot) + } +} From 78ea2fb92a91e57f191cd1c9909b8f05f9e58db1 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:45:58 +0200 Subject: [PATCH 3/3] fix(test): update the restore test for the new constructor and parser method The instrumented test was left behind: PlaylistRepositoryImpl gained a channelDetailsDao parameter and XtreamParser gained getChannelDetailsOrNull, and the test's fake parser implemented neither. CI caught it. Co-Authored-By: Claude Opus 5 (1M context) --- .../playlist/PlaylistRepositoryProviderRestoreTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/data/src/androidTest/java/com/m3u/data/repository/playlist/PlaylistRepositoryProviderRestoreTest.kt b/data/src/androidTest/java/com/m3u/data/repository/playlist/PlaylistRepositoryProviderRestoreTest.kt index fb58df609..a72786948 100644 --- a/data/src/androidTest/java/com/m3u/data/repository/playlist/PlaylistRepositoryProviderRestoreTest.kt +++ b/data/src/androidTest/java/com/m3u/data/repository/playlist/PlaylistRepositoryProviderRestoreTest.kt @@ -16,6 +16,7 @@ import com.m3u.data.database.model.Playlist import com.m3u.data.database.model.ProviderAccount import com.m3u.data.database.model.ProviderCredentialEntity import com.m3u.data.parser.m3u.M3UParserImpl +import com.m3u.data.parser.xtream.XtreamChannelDetails import com.m3u.data.parser.xtream.XtreamChannelInfo import com.m3u.data.parser.xtream.XtreamData import com.m3u.data.parser.xtream.XtreamInfo @@ -897,6 +898,7 @@ class PlaylistRepositoryProviderRestoreTest { val repository = PlaylistRepositoryImpl( playlistDao = database.playlistDao(), channelDao = database.channelDao(), + channelDetailsDao = database.channelDetailsDao(), providerDao = database.providerDao(), database = database, providerLifecycleCoordinator = ProviderLifecycleCoordinator(), @@ -1006,6 +1008,12 @@ class PlaylistRepositoryProviderRestoreTest { seriesId: Int, ): XtreamChannelInfo = error("Not used") + override suspend fun getChannelDetailsOrNull( + input: XtreamInput, + kind: XtreamParser.ChannelDetailsKind, + id: Int, + ): XtreamChannelDetails? = null + override fun parse(input: XtreamInput): Flow = flow { emit( XtreamLive(