diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt index baddda108..3db2959f8 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt @@ -23,6 +23,7 @@ import com.m3u.data.repository.tv.ConnectionToTvValue import com.m3u.data.repository.tv.TvRepository import com.m3u.data.tv.model.RemoteDirection import com.m3u.data.tv.model.TvInfo +import com.m3u.data.worker.ChannelDetailsWorker import com.m3u.data.worker.SubscriptionWorker import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue import dagger.hilt.android.lifecycle.HiltViewModel @@ -79,10 +80,11 @@ class AppViewModel @Inject constructor( ) .flow .map { data -> - data.pagingMap { channel -> + data.pagingMap { result -> ChannelWithProgramme( - channel = channel, - programme = null + channel = result.channel, + programme = null, + matchedCast = result.matchedCast, ) } } @@ -114,6 +116,23 @@ class AppViewModel @Inject constructor( } }.cachedIn(viewModelScope) + /** + * Resumes collecting channel descriptions on every start. + * + * Cheap to call: the worker asks for channels that still have no + * description and finishes at once when there are none, and KEEP means an + * ongoing sweep is never restarted. Hooked here rather than only after a + * subscription so a catalogue that was interrupted — a few tens of + * thousands of titles are not collected in one sitting — carries on by + * itself, without asking anyone to re-subscribe. + */ + private fun collectChannelDetails() { + viewModelScope.launch { + runCatching { ChannelDetailsWorker.enqueue(workManager) } + .onFailure { throwable -> timber.w(throwable, "details sweep not scheduled") } + } + } + private fun refreshProgrammes() { viewModelScope.launch { val playlists = playlistRepository.getAllAutoRefresh() @@ -136,6 +155,7 @@ class AppViewModel @Inject constructor( init { refreshProgrammes() + collectChannelDetails() tvRepository.connected .onEach { timber.d("connected tv changed: $it") diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt index d723bba6d..5114b9cb5 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt @@ -111,6 +111,7 @@ internal fun ChannelGallery( ChannelItem( channel = channel, programme = channelWithProgramme.programme, + matchedCast = channelWithProgramme.matchedCast, cover = loadedUrl, recently = recently, zapping = zapping == channel, diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt index 16fb6f4f5..ac6a40d90 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt @@ -71,6 +71,8 @@ internal fun ChannelItem( onClick: () -> Unit, onLongClick: () -> Unit, programme: Programme?, + /** Cast list when an actor, not the title, is why this row matched. */ + matchedCast: String? = null, modifier: Modifier = Modifier, isVodOrSeriesPlaylist: Boolean = true ) { @@ -189,6 +191,18 @@ internal fun ChannelItem( }, supportingContent = { when { + // Comes first: on a search for an actor, the title + // alone gives no clue why the row is in the list. + matchedCast != null -> { + Text( + text = matchedCast, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + recently -> { Text( text = remember(channel.seen) { 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/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt b/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt index 381dc7a99..c1d57dd71 100644 --- a/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt +++ b/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt @@ -78,6 +78,11 @@ import kotlin.time.Duration.Companion.seconds data class ChannelWithProgramme( val channel: Channel, val programme: Programme?, + /** + * Set only on search results a cast list matched rather than the title, + * so the row can say why it is there. Null everywhere else. + */ + val matchedCast: String? = null, ) @HiltViewModel diff --git a/core/foundation/src/main/java/com/m3u/core/foundation/util/basic/Strings.kt b/core/foundation/src/main/java/com/m3u/core/foundation/util/basic/Strings.kt index 7cbfec224..6190d7452 100644 --- a/core/foundation/src/main/java/com/m3u/core/foundation/util/basic/Strings.kt +++ b/core/foundation/src/main/java/com/m3u/core/foundation/util/basic/Strings.kt @@ -3,6 +3,8 @@ package com.m3u.core.foundation.util.basic import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.intl.Locale +import java.text.Normalizer +import java.util.Locale as JavaLocale fun String.title(): String { if (this.isEmpty()) return this @@ -20,4 +22,28 @@ fun String.startsWithAny(vararg prefix: String, ignoreCase: Boolean = false): Bo fun String.startWithHttpScheme(): Boolean = startsWithAny( "http://", "https://", ignoreCase = true -) \ No newline at end of file +) + +/** + * Folds a string down to the form search compares against: no diacritics, no + * case. + * + * SQLite's LIKE ignores case for ASCII but never accents, so "prenom" cannot + * match "Le Prénom" however the query is written — and a catalogue in French + * has a lot of those. Titles are stored pre-folded in a dedicated column and + * the query is folded the same way, which keeps the comparison symmetric: with + * or without accents, either side matches. + * + * NFD splits an accented letter into its base letter plus a combining mark, so + * dropping the marks (Unicode category Mn) leaves the base letter behind. + * Scripts without combining marks — CJK among them — pass through untouched. + * + * Locale.ROOT on purpose: a Turkish locale lowercases 'I' to a dotless 'ı', + * which would quietly make titles unsearchable for those users. + */ +fun String.normalizeForSearch(): String = Normalizer + .normalize(this, Normalizer.Form.NFD) + .replace(COMBINING_MARKS, "") + .lowercase(JavaLocale.ROOT) + +private val COMBINING_MARKS = Regex("\\p{Mn}+") \ No newline at end of file diff --git a/core/foundation/src/test/java/com/m3u/core/foundation/util/basic/NormalizeForSearchTest.kt b/core/foundation/src/test/java/com/m3u/core/foundation/util/basic/NormalizeForSearchTest.kt new file mode 100644 index 000000000..4d34bd570 --- /dev/null +++ b/core/foundation/src/test/java/com/m3u/core/foundation/util/basic/NormalizeForSearchTest.kt @@ -0,0 +1,45 @@ +package com.m3u.core.foundation.util.basic + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NormalizeForSearchTest { + @Test + fun `accents are folded away`() { + assertEquals("le prenom", "Le Prénom".normalizeForSearch()) + assertEquals("amelie", "Amélie".normalizeForSearch()) + assertEquals("a bout de souffle", "À bout de souffle".normalizeForSearch()) + assertEquals("les miserables", "Les Misérables".normalizeForSearch()) + } + + @Test + fun `a query typed with accents matches the same folded form`() { + assertEquals("Le Prénom".normalizeForSearch(), "le prenom".normalizeForSearch()) + assertEquals("Le Prénom".normalizeForSearch(), "LE PRÉNOM".normalizeForSearch()) + } + + @Test + fun `case is folded independently of the device locale`() { + val previous = java.util.Locale.getDefault() + try { + // Turkish lowercases 'I' to a dotless 'ı'; using the default locale + // here would make "IT" unsearchable for those users. + java.util.Locale.setDefault(java.util.Locale.forLanguageTag("tr-TR")) + assertEquals("it crowd", "IT Crowd".normalizeForSearch()) + } finally { + java.util.Locale.setDefault(previous) + } + } + + @Test + fun `scripts without combining marks are left alone`() { + assertEquals("千と千尋の神隠し", "千と千尋の神隠し".normalizeForSearch()) + assertEquals("привет", "Привет".normalizeForSearch()) + } + + @Test + fun `punctuation and spacing survive so substring matching still works`() { + assertEquals("spider-man: no way home", "Spider-Man: No Way Home".normalizeForSearch()) + assertEquals("", "".normalizeForSearch()) + } +} 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/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..321a096f6 --- /dev/null +++ b/data/schemas/com.m3u.data.database.M3UDatabase/27.json @@ -0,0 +1,840 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "bca92df38500d6fce82a620dab053867", + "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, `title_normalized` TEXT NOT NULL DEFAULT '')", + "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" + }, + { + "fieldPath": "titleNormalized", + "columnName": "title_normalized", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "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" + ] + } + ] + } + ], + "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, 'bca92df38500d6fce82a620dab053867')" + ] + } +} \ No newline at end of file diff --git a/data/schemas/com.m3u.data.database.M3UDatabase/28.json b/data/schemas/com.m3u.data.database.M3UDatabase/28.json new file mode 100644 index 000000000..e8bbd05bb --- /dev/null +++ b/data/schemas/com.m3u.data.database.M3UDatabase/28.json @@ -0,0 +1,935 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "e86ec07f21efa7de837b7786c0b3d2aa", + "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, `title_normalized` TEXT NOT NULL DEFAULT '')", + "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" + }, + { + "fieldPath": "titleNormalized", + "columnName": "title_normalized", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "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, `cast_normalized` 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": "castNormalized", + "columnName": "cast_normalized", + "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, 'e86ec07f21efa7de837b7786c0b3d2aa')" + ] + } +} \ No newline at end of file diff --git a/data/schemas/com.m3u.data.database.M3UDatabase/29.json b/data/schemas/com.m3u.data.database.M3UDatabase/29.json new file mode 100644 index 000000000..cf817d49d --- /dev/null +++ b/data/schemas/com.m3u.data.database.M3UDatabase/29.json @@ -0,0 +1,922 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "c05bd8a636ca254d45c7fe092abbcfe6", + "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, `title_normalized` TEXT NOT NULL DEFAULT '')", + "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" + }, + { + "fieldPath": "titleNormalized", + "columnName": "title_normalized", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "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, `cast_normalized` 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", + "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": "castNormalized", + "columnName": "cast_normalized", + "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`)" + } + ] + } + ], + "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, 'c05bd8a636ca254d45c7fe092abbcfe6')" + ] + } +} \ No newline at end of file diff --git a/data/src/androidTest/java/com/m3u/data/database/Migration21To22Test.kt b/data/src/androidTest/java/com/m3u/data/database/Migration21To22Test.kt index 3b0f461c9..7ec45ecca 100644 --- a/data/src/androidTest/java/com/m3u/data/database/Migration21To22Test.kt +++ b/data/src/androidTest/java/com/m3u/data/database/Migration21To22Test.kt @@ -50,6 +50,8 @@ class Migration21To22Test { .addMigrations(DatabaseMigrations.migration22To23(TestCredentialVault)) .addMigrations(DatabaseMigrations.MIGRATION_24_25) .addMigrations(DatabaseMigrations.MIGRATION_25_26) + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() val migrated = database.openHelper.writableDatabase diff --git a/data/src/androidTest/java/com/m3u/data/database/Migration22To23Test.kt b/data/src/androidTest/java/com/m3u/data/database/Migration22To23Test.kt index 7e572fbca..6075a994a 100644 --- a/data/src/androidTest/java/com/m3u/data/database/Migration22To23Test.kt +++ b/data/src/androidTest/java/com/m3u/data/database/Migration22To23Test.kt @@ -51,6 +51,8 @@ class Migration22To23Test { .addMigrations(DatabaseMigrations.migration22To23(TestCredentialVault)) .addMigrations(DatabaseMigrations.MIGRATION_24_25) .addMigrations(DatabaseMigrations.MIGRATION_25_26) + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() val migrated = database.openHelper.writableDatabase @@ -111,6 +113,8 @@ class Migration22To23Test { .addMigrations(DatabaseMigrations.migration22To23(FailingCredentialVault)) .addMigrations(DatabaseMigrations.MIGRATION_24_25) .addMigrations(DatabaseMigrations.MIGRATION_25_26) + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() val migrated = database.openHelper.writableDatabase diff --git a/data/src/androidTest/java/com/m3u/data/database/Migration23To24Test.kt b/data/src/androidTest/java/com/m3u/data/database/Migration23To24Test.kt index d533e0671..cee524f97 100644 --- a/data/src/androidTest/java/com/m3u/data/database/Migration23To24Test.kt +++ b/data/src/androidTest/java/com/m3u/data/database/Migration23To24Test.kt @@ -74,6 +74,8 @@ class Migration23To24Test { .allowMainThreadQueries() .addMigrations(DatabaseMigrations.MIGRATION_24_25) .addMigrations(DatabaseMigrations.MIGRATION_25_26) + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() val migrated = database.openHelper.writableDatabase diff --git a/data/src/androidTest/java/com/m3u/data/database/Migration24To25Test.kt b/data/src/androidTest/java/com/m3u/data/database/Migration24To25Test.kt index 35f73840d..17aba5d67 100644 --- a/data/src/androidTest/java/com/m3u/data/database/Migration24To25Test.kt +++ b/data/src/androidTest/java/com/m3u/data/database/Migration24To25Test.kt @@ -233,6 +233,8 @@ class Migration24To25Test { .allowMainThreadQueries() .addMigrations(DatabaseMigrations.MIGRATION_24_25) .addMigrations(DatabaseMigrations.MIGRATION_25_26) + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() val migrated = database.openHelper.writableDatabase diff --git a/data/src/androidTest/java/com/m3u/data/database/Migration25To26Test.kt b/data/src/androidTest/java/com/m3u/data/database/Migration25To26Test.kt index 5289e9693..9e1fe3695 100644 --- a/data/src/androidTest/java/com/m3u/data/database/Migration25To26Test.kt +++ b/data/src/androidTest/java/com/m3u/data/database/Migration25To26Test.kt @@ -60,6 +60,8 @@ class Migration25To26Test { val database = Room.databaseBuilder(context, M3UDatabase::class.java, DATABASE_NAME) .allowMainThreadQueries() .addMigrations(DatabaseMigrations.MIGRATION_25_26) + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() val migrated = database.openHelper.writableDatabase diff --git a/data/src/androidTest/java/com/m3u/data/database/Migration26To27Test.kt b/data/src/androidTest/java/com/m3u/data/database/Migration26To27Test.kt new file mode 100644 index 000000000..30eefdbeb --- /dev/null +++ b/data/src/androidTest/java/com/m3u/data/database/Migration26To27Test.kt @@ -0,0 +1,132 @@ +package com.m3u.data.database + +import android.content.Context +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The folded title column is only useful if the rows already on disk get filled + * in — a user who has been running the app for months never re-imports their + * catalogue, so a migration that only adds the column would leave search broken + * for exactly the people who have the most channels. + */ +@RunWith(AndroidJUnit4::class) +class Migration26To27Test { + @get:Rule + val migrationHelper = MigrationTestHelper( + instrumentation = InstrumentationRegistry.getInstrumentation(), + databaseClass = M3UDatabase::class.java, + ) + + @Test + fun migrationFoldsExistingTitles() { + migrationHelper.createDatabase(DATABASE_NAME, 26).apply { + insertPlaylist(PLAYLIST_URL, "Provider") + TITLES.forEachIndexed { index, title -> + insertChannel(id = index + 1, title = title) + } + close() + } + + val database = Room + .databaseBuilder( + ApplicationProvider.getApplicationContext(), + M3UDatabase::class.java, + DATABASE_NAME, + ) + .allowMainThreadQueries() + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) + .build() + val migrated = database.openHelper.writableDatabase + + assertEquals( + listOf( + "le prenom", + "amelie", + "a bout de souffle", + "spider-man: no way home", + "千と千尋の神隠し", + ), + migrated.readColumn("title_normalized"), + ) + // The displayed title is untouched — only the search copy is folded. + assertEquals(TITLES, migrated.readColumn("title")) + database.close() + } + + @Test + fun migrationCoversRowsBeyondASingleBatch() { + // The backfill walks the table in keyed batches; a catalogue larger than + // one batch must come out entirely folded, not just its first page. + val count = 1_200 + migrationHelper.createDatabase(DATABASE_NAME, 26).apply { + insertPlaylist(PLAYLIST_URL, "Provider") + repeat(count) { index -> insertChannel(id = index + 1, title = "Épisode $index") } + close() + } + + val database = Room + .databaseBuilder( + ApplicationProvider.getApplicationContext(), + M3UDatabase::class.java, + DATABASE_NAME, + ) + .allowMainThreadQueries() + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) + .build() + val migrated = database.openHelper.writableDatabase + + migrated.query( + "SELECT COUNT(*) FROM streams WHERE title_normalized LIKE 'episode %'" + ).use { cursor -> + cursor.moveToFirst() + assertEquals(count, cursor.getInt(0)) + } + database.close() + } + + private fun SupportSQLiteDatabase.readColumn(column: String): List = buildList { + query("SELECT $column FROM streams ORDER BY id").use { cursor -> + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + + private fun SupportSQLiteDatabase.insertPlaylist(url: String, title: String) { + execSQL( + "INSERT INTO playlists (url, title) VALUES (?, ?)", + arrayOf(url, title), + ) + } + + private fun SupportSQLiteDatabase.insertChannel(id: Int, title: String) { + execSQL( + """ + INSERT INTO streams (id, url, `group`, title, playlist_url, favourite, hidden, seen) + VALUES (?, ?, ?, ?, ?, 0, 0, 0) + """.trimIndent(), + arrayOf(id, "http://example.test/$id.mkv", "Films", title, PLAYLIST_URL), + ) + } + + private companion object { + const val DATABASE_NAME = "migration-26-27" + const val PLAYLIST_URL = "http://example.test/playlist.m3u" + val TITLES = listOf( + "Le Prénom", + "Amélie", + "À bout de souffle", + "Spider-Man: No Way Home", + "千と千尋の神隠し", + ) + } +} diff --git a/data/src/androidTest/java/com/m3u/data/database/Migration28To29Test.kt b/data/src/androidTest/java/com/m3u/data/database/Migration28To29Test.kt new file mode 100644 index 000000000..106abff51 --- /dev/null +++ b/data/src/androidTest/java/com/m3u/data/database/Migration28To29Test.kt @@ -0,0 +1,97 @@ +package com.m3u.data.database + +import android.content.Context +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Cached descriptions must survive a catalogue refresh. + * + * Re-subscribing is the only way to refresh a playlist and it writes the row + * back with INSERT OR REPLACE, which SQLite performs as a delete followed by + * an insert. While channel_details carried an ON DELETE CASCADE onto playlists + * that emptied the whole table on every refresh — and each row costs one + * network request to earn back. + */ +@RunWith(AndroidJUnit4::class) +class Migration28To29Test { + @get:Rule + val migrationHelper = MigrationTestHelper( + instrumentation = InstrumentationRegistry.getInstrumentation(), + databaseClass = M3UDatabase::class.java, + ) + + @Test + fun cachedDetailsOutliveAPlaylistRefresh() { + migrationHelper.createDatabase(DATABASE_NAME, 28).apply { + insertPlaylist() + insertDetails(reference = "12345", cast = "Jake Gyllenhaal, Riz Ahmed") + insertDetails(reference = "67890", cast = "Jean Dujardin") + close() + } + + val database = Room + .databaseBuilder( + ApplicationProvider.getApplicationContext(), + M3UDatabase::class.java, + DATABASE_NAME, + ) + .allowMainThreadQueries() + .addMigrations(DatabaseMigrations.MIGRATION_28_29) + .build() + val migrated = database.openHelper.writableDatabase + + assertEquals(2, migrated.countDetails()) + + // Exactly what a re-subscription does to the playlist row. + migrated.execSQL("PRAGMA foreign_keys = ON") + migrated.insertPlaylist(orReplace = true) + + assertEquals(2, migrated.countDetails()) + migrated.query( + "SELECT `cast` FROM channel_details WHERE channel_reference = '12345'" + ).use { cursor -> + cursor.moveToFirst() + assertEquals("Jake Gyllenhaal, Riz Ahmed", cursor.getString(0)) + } + database.close() + } + + private fun SupportSQLiteDatabase.countDetails(): Int = + query("SELECT COUNT(*) FROM channel_details").use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + + private fun SupportSQLiteDatabase.insertPlaylist(orReplace: Boolean = false) { + val verb = if (orReplace) "INSERT OR REPLACE" else "INSERT" + execSQL( + "$verb INTO playlists (url, title) VALUES (?, ?)", + arrayOf(PLAYLIST_URL, "Provider"), + ) + } + + private fun SupportSQLiteDatabase.insertDetails(reference: String, cast: String) { + execSQL( + """ + INSERT INTO channel_details + (playlist_url, channel_reference, `cast`, cast_normalized, fetched_at) + VALUES (?, ?, ?, ?, ?) + """.trimIndent(), + arrayOf(PLAYLIST_URL, reference, cast, cast.lowercase(), 1L), + ) + } + + private companion object { + const val DATABASE_NAME = "migration-28-29" + const val PLAYLIST_URL = "http://example.test/playlist.m3u" + } +} 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( diff --git a/data/src/main/java/com/m3u/data/database/DatabaseMigrations.kt b/data/src/main/java/com/m3u/data/database/DatabaseMigrations.kt index 99be2a363..4e8681908 100644 --- a/data/src/main/java/com/m3u/data/database/DatabaseMigrations.kt +++ b/data/src/main/java/com/m3u/data/database/DatabaseMigrations.kt @@ -6,6 +6,7 @@ import androidx.room.RenameTable import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.m3u.core.foundation.util.basic.normalizeForSearch import com.m3u.data.extension.security.CredentialVault import com.m3u.extension.api.ExtensionId import com.m3u.extension.api.subscription.ProviderKind @@ -483,6 +484,104 @@ internal object DatabaseMigrations { ) } + /** + * Adds the folded copy of every channel title that search compares against. + * + * The backfill runs in Kotlin rather than in SQL so it goes through the very + * same [normalizeForSearch] the writes and the queries use. Spelling the + * fold out as a stack of SQL REPLACE calls would work today and drift from + * the Kotlin version the first time either side is touched — and the two + * disagreeing is invisible: rows simply stop matching. + * + * Rows are walked in keyed batches instead of one long cursor, so nothing + * is updated underneath an open cursor and memory stays flat whatever the + * catalogue size. Xtream playlists here run to about 41 000 rows. + */ + val MIGRATION_26_27 = object : Migration(26, 27) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE streams ADD COLUMN title_normalized TEXT NOT NULL DEFAULT ''" + ) + val update = db.compileStatement( + "UPDATE streams SET title_normalized = ? WHERE id = ?" + ) + var lastId = Int.MIN_VALUE + while (true) { + val batch = buildList { + db.query( + "SELECT id, title FROM streams WHERE id > ? ORDER BY id LIMIT ?", + arrayOf(lastId, BACKFILL_BATCH_SIZE), + ).use { cursor -> + while (cursor.moveToNext()) { + add(cursor.getInt(0) to cursor.getString(1)) + } + } + } + if (batch.isEmpty()) break + batch.forEach { (id, title) -> + update.clearBindings() + update.bindString(1, title.normalizeForSearch()) + update.bindLong(2, id.toLong()) + update.executeUpdateDelete() + } + lastId = batch.last().first + } + } + } + + /** + * Drops the foreign key that was emptying channel_details on every refresh. + * + * Re-subscribing writes the playlist back with INSERT OR REPLACE, which + * SQLite performs as a delete followed by an insert; the ON DELETE CASCADE + * then took every cached description with it. Measured on a real database: + * 401 rows before, 0 after — and each one costs a request to earn back. + * + * Rows now go away only when a playlist is genuinely unsubscribed, which + * PlaylistRepository does explicitly. + */ + val MIGRATION_28_29 = object : Migration(28, 29) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `channel_details_new` ( + `playlist_url` TEXT NOT NULL, + `channel_reference` TEXT NOT NULL, + `plot` TEXT, + `cast` TEXT, + `cast_normalized` TEXT, + `director` TEXT, + `genre` TEXT, + `rating` TEXT, + `release_date` TEXT, + `duration_seconds` INTEGER, + `fetched_at` INTEGER NOT NULL, + PRIMARY KEY(`playlist_url`, `channel_reference`) + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT OR REPLACE INTO `channel_details_new` + SELECT `playlist_url`, `channel_reference`, `plot`, `cast`, + `cast_normalized`, `director`, `genre`, `rating`, + `release_date`, `duration_seconds`, `fetched_at` + FROM `channel_details` + """.trimIndent() + ) + db.execSQL("DROP TABLE `channel_details`") + db.execSQL("ALTER TABLE `channel_details_new` RENAME TO `channel_details`") + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS `index_channel_details_playlist_url` + ON `channel_details` (`playlist_url`) + """.trimIndent() + ) + } + } + + private const val BACKFILL_BATCH_SIZE = 500 + private fun SupportSQLiteDatabase.enableSecureDelete() { query("PRAGMA secure_delete = ON").use { cursor -> check(cursor.moveToFirst() && cursor.getInt(0) == 1) { 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..938ba89ea 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 @@ -50,6 +51,8 @@ internal object DatabaseModule { .addMigrations(DatabaseMigrations.migration22To23(credentialVault)) .addMigrations(DatabaseMigrations.MIGRATION_24_25) .addMigrations(DatabaseMigrations.MIGRATION_25_26) + .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() @Provides @@ -58,6 +61,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..e8859f434 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 = 29, 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 = 27, to = 28), ] ) @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/ChannelDao.kt b/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt index 1525e4e10..4c337056f 100644 --- a/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt +++ b/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt @@ -11,6 +11,7 @@ import androidx.room.Upsert import com.m3u.data.database.model.AdjacentChannels import com.m3u.data.database.model.Channel import com.m3u.data.database.model.ChannelMetadataBase +import com.m3u.data.database.model.ChannelSearchResult import com.m3u.data.database.model.ExtensionChannelMetadataOverlay import kotlinx.coroutines.flow.Flow @@ -184,7 +185,7 @@ interface ChannelDao { SELECT DISTINCT `group` FROM streams WHERE playlist_url = :playlistUrl - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' """ ) suspend fun getCategoriesByPlaylistUrl( @@ -197,7 +198,7 @@ interface ChannelDao { SELECT DISTINCT `group` FROM streams WHERE playlist_url = :playlistUrl - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' """ ) fun observeCategoriesByPlaylistUrl( @@ -276,7 +277,15 @@ interface ChannelDao { stream.favourite AS favourite, stream.hidden AS hidden, stream.seen AS seen, - stream.relation_id AS relation_id + stream.relation_id AS relation_id, + -- Present only because Room requires every non-null field to be + -- returned. This projection overrides the title with an extension + -- overlay, so the folded copy taken from the source row may not + -- match it — which costs nothing here: the field sits outside the + -- constructor, so it is neither serialised into the backup nor + -- carried across a restore. It is recomputed from whatever title + -- the channel is rebuilt with. + stream.title_normalized AS title_normalized FROM streams AS stream LEFT JOIN channel_metadata_bases AS base ON base.playlist_url = stream.playlist_url @@ -288,6 +297,29 @@ interface ChannelDao { @Query("SELECT * FROM streams WHERE playlist_url = :playlistUrl AND relation_id = :relationId") suspend fun getByPlaylistUrlAndRelationId(playlistUrl: String, relationId: String): Channel? + /** + * Channels of a playlist whose description has not been fetched yet. + * + * The background sweep asks for one batch at a time and re-asks: rows it + * has dealt with drop out of the result on their own, so it resumes where + * it stopped without keeping a cursor of its own — which matters when the + * work is spread over several nights and interrupted by every playback. + */ + @Query( + """ + SELECT stream.* FROM streams AS stream + LEFT JOIN channel_details AS details + ON details.playlist_url = stream.playlist_url + AND details.channel_reference = stream.relation_id + WHERE stream.playlist_url = :playlistUrl + AND stream.relation_id IS NOT NULL + AND stream.relation_id != '' + AND details.channel_reference IS NULL + LIMIT :limit + """ + ) + suspend fun getWithoutDetails(playlistUrl: String, limit: Int): List + @Query("SELECT * FROM streams WHERE relation_id IN (:relationIds) AND hidden = 0") suspend fun getByRelationIds(relationIds: List): List @@ -313,7 +345,7 @@ interface ChannelDao { """ SELECT * FROM streams WHERE playlist_url = :url - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' AND `group` = :category """ ) @@ -327,7 +359,7 @@ interface ChannelDao { """ SELECT * FROM streams WHERE playlist_url = :url - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' AND `group` = :category ORDER BY title ASC """ @@ -342,7 +374,7 @@ interface ChannelDao { """ SELECT * FROM streams WHERE playlist_url = :url - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' AND `group` = :category ORDER BY title DESC """ @@ -357,7 +389,7 @@ interface ChannelDao { """ SELECT * FROM streams WHERE playlist_url = :url - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' AND `group` = :category ORDER BY seen DESC """ @@ -372,7 +404,7 @@ interface ChannelDao { """ SELECT * FROM streams WHERE playlist_url = :url - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' """ ) fun pagingAllByPlaylistUrlMixed( @@ -430,15 +462,40 @@ interface ChannelDao { ): Flow + /** + * Searches titles and, where they have been fetched, cast lists. + * + * The join is a LEFT one so a catalogue whose descriptions have not been + * collected yet searches exactly as it did before — the actor half simply + * matches nothing until the background sweep has filled it in. + * + * matched_cast carries why a row came back: null when the title itself + * matched, the cast list when only an actor did. Without it a search for + * "Gyllenhaal" returns films whose titles have nothing to do with the + * query, and the list reads as broken. + * + * @param query must already be folded with normalizeForSearch — the columns + * it is compared against hold folded text, so an unfolded query would match + * nothing as soon as it carried an accent or a capital. + */ @Query( """ - SELECT * FROM streams WHERE 1 - AND title LIKE '%'||:query||'%' + SELECT stream.*, + CASE + WHEN stream.title_normalized LIKE '%'||:query||'%' THEN NULL + ELSE details.`cast` + END AS matched_cast + FROM streams AS stream + LEFT JOIN channel_details AS details + ON details.playlist_url = stream.playlist_url + AND details.channel_reference = stream.relation_id + WHERE stream.title_normalized LIKE '%'||:query||'%' + OR details.cast_normalized LIKE '%'||:query||'%' """ ) fun query( query: String - ): PagingSource + ): PagingSource @Query( """ @@ -475,7 +532,7 @@ interface ChannelDao { @Query( """ SELECT * FROM streams WHERE 1 - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' """ ) fun pagingAll(query: String): PagingSource 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..8d960147d --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt @@ -0,0 +1,49 @@ +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 + + /** + * Called when a playlist is unsubscribed for good. + * + * This replaces the ON DELETE CASCADE this table used to carry: cascading + * also fired on the INSERT OR REPLACE a refresh performs, throwing away + * 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/Channel.kt b/data/src/main/java/com/m3u/data/database/model/Channel.kt index 2b65c1b3a..2f05b51e0 100644 --- a/data/src/main/java/com/m3u/data/database/model/Channel.kt +++ b/data/src/main/java/com/m3u/data/database/model/Channel.kt @@ -6,6 +6,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import com.m3u.annotation.Exclude import com.m3u.annotation.Likable +import com.m3u.core.foundation.util.basic.normalizeForSearch import com.m3u.data.parser.xtream.XtreamEpisodeInfo import io.ktor.http.URLBuilder import io.ktor.http.Url @@ -65,6 +66,24 @@ data class Channel( */ val relationId: String? = null ) { + /** + * [title] with diacritics and case folded away — what search compares + * against, since SQLite's LIKE never ignores accents on its own. + * + * Declared outside the constructor on purpose. A data class only copies + * constructor parameters, so every construction path recomputes this from + * whatever [title] ends up being — including copy(title = …), which would + * otherwise carry a stale value forward and silently drop the channel out + * of every search. The invariant holds by construction rather than by + * remembering to maintain it. + */ + // No index: search matches on '%query%', which no B-tree index can serve, + // and one more index would only slow down the bulk inserts a resubscription + // performs on tens of thousands of rows. + @ColumnInfo(name = "title_normalized", defaultValue = "''") + @Exclude + var titleNormalized: String = title.normalizeForSearch() + companion object { const val URL_DYNAMIC = "dynamic" const val LICENSE_TYPE_WIDEVINE = "com.widevine.alpha" 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..47dfdc7aa --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt @@ -0,0 +1,74 @@ +package com.m3u.data.database.model + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +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. + */ +/* + * Deliberately without a foreign key onto playlists. + * + * Re-subscribing is the only way to refresh a catalogue, and it writes the + * playlist back with INSERT OR REPLACE. SQLite implements REPLACE as a delete + * followed by an insert, so an ON DELETE CASCADE here empties this table on + * every refresh — measured: 401 rows before, 0 after. That is the exact + * opposite of the point, since re-earning them costs one request per title. + * + * 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"], + 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, + /** + * [cast] folded the way titles are, so searching an actor can reuse the + * same comparison. Filled even when nothing searches it yet. + */ + @ColumnInfo(name = "cast_normalized") + val castNormalized: 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/database/model/ChannelSearchResult.kt b/data/src/main/java/com/m3u/data/database/model/ChannelSearchResult.kt new file mode 100644 index 000000000..1a27d2c34 --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/model/ChannelSearchResult.kt @@ -0,0 +1,22 @@ +package com.m3u.data.database.model + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Embedded + +/** + * A search hit, and why it is one. + * + * Searching only titles needs no explanation. Once cast lists are searched + * too, a query like "Gyllenhaal" returns films whose titles share nothing + * with it, and a plain list of them reads as a bug — so the matching cast is + * carried alongside and shown. + */ +@Immutable +data class ChannelSearchResult( + @Embedded + val channel: Channel, + /** Null when the title matched; the cast list when only an actor did. */ + @ColumnInfo(name = "matched_cast") + val matchedCast: String? = null, +) 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..f296be916 --- /dev/null +++ b/data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepositoryImpl.kt @@ -0,0 +1,110 @@ +package com.m3u.data.repository.channel + +import com.m3u.core.foundation.util.basic.normalizeForSearch +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, + castNormalized = cast?.normalizeForSearch(), + 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/data/src/main/java/com/m3u/data/repository/channel/ChannelRepository.kt b/data/src/main/java/com/m3u/data/repository/channel/ChannelRepository.kt index e1f0e8679..3543cb149 100644 --- a/data/src/main/java/com/m3u/data/repository/channel/ChannelRepository.kt +++ b/data/src/main/java/com/m3u/data/repository/channel/ChannelRepository.kt @@ -5,6 +5,7 @@ import androidx.paging.PagingSource import com.m3u.core.foundation.wrapper.Sort import com.m3u.data.database.model.AdjacentChannels import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelSearchResult import kotlinx.coroutines.flow.Flow import kotlin.time.Duration @@ -39,5 +40,5 @@ interface ChannelRepository { fun observeAllFavorite(): Flow> fun pagingAllFavorite(sort: Sort): PagingSource fun observeAllHidden(): Flow> - fun search(query: String): PagingSource + fun search(query: String): PagingSource } diff --git a/data/src/main/java/com/m3u/data/repository/channel/ChannelRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/channel/ChannelRepositoryImpl.kt index 3dd17762d..0746db44d 100644 --- a/data/src/main/java/com/m3u/data/repository/channel/ChannelRepositoryImpl.kt +++ b/data/src/main/java/com/m3u/data/repository/channel/ChannelRepositoryImpl.kt @@ -2,11 +2,13 @@ package com.m3u.data.repository.channel import androidx.paging.PagingSource import com.m3u.core.foundation.architecture.preferences.Settings +import com.m3u.core.foundation.util.basic.normalizeForSearch import com.m3u.core.foundation.wrapper.Sort import com.m3u.data.database.dao.ChannelDao import com.m3u.data.database.dao.PlaylistDao import com.m3u.data.database.model.AdjacentChannels import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelSearchResult import com.m3u.data.repository.playlist.PlaylistDataMaintenanceCoordinator import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -31,8 +33,12 @@ internal class ChannelRepositoryImpl @Inject constructor( .observeRelationIdsByPlaylistUrl(playlistUrl) .catch { emit(emptyList()) } + // Every user-typed query is folded here rather than at each call site. + // The DAO compares against a folded column, so a raw query would stop + // matching the moment it carried an accent or a capital — and callers + // would have no way of telling, since the result is simply an empty list. override fun pagingAll(query: String): PagingSource { - return channelDao.pagingAll(query) + return channelDao.pagingAll(query.normalizeForSearch()) } override fun pagingAllByPlaylistUrl( @@ -40,12 +46,15 @@ internal class ChannelRepositoryImpl @Inject constructor( category: String, query: String, sort: Sort - ): PagingSource = when (sort) { - Sort.UNSPECIFIED -> channelDao.pagingAllByPlaylistUrl(url, category, query) - Sort.ASC -> channelDao.pagingAllByPlaylistUrlAsc(url, category, query) - Sort.DESC -> channelDao.pagingAllByPlaylistUrlDesc(url, category, query) - Sort.RECENTLY -> channelDao.pagingAllByPlaylistUrlRecently(url, category, query) - Sort.MIXED -> channelDao.pagingAllByPlaylistUrlMixed(url, query) + ): PagingSource { + val folded = query.normalizeForSearch() + return when (sort) { + Sort.UNSPECIFIED -> channelDao.pagingAllByPlaylistUrl(url, category, folded) + Sort.ASC -> channelDao.pagingAllByPlaylistUrlAsc(url, category, folded) + Sort.DESC -> channelDao.pagingAllByPlaylistUrlDesc(url, category, folded) + Sort.RECENTLY -> channelDao.pagingAllByPlaylistUrlRecently(url, category, folded) + Sort.MIXED -> channelDao.pagingAllByPlaylistUrlMixed(url, folded) + } } override suspend fun get(id: Int): Channel? = channelDao.get(id) @@ -150,7 +159,7 @@ internal class ChannelRepositoryImpl @Inject constructor( override fun observeAllHidden(): Flow> = channelDao.observeAllHidden() .catch { emit(emptyList()) } - override fun search(query: String): PagingSource { - return channelDao.query(query) + override fun search(query: String): PagingSource { + return channelDao.query(query.normalizeForSearch()) } } 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..932716ac4 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 @@ -12,11 +12,13 @@ import com.m3u.core.foundation.architecture.preferences.PreferencesKeys import com.m3u.core.foundation.architecture.preferences.Settings import com.m3u.core.foundation.architecture.preferences.get import com.m3u.core.foundation.util.basic.PlaylistInputKind +import com.m3u.core.foundation.util.basic.normalizeForSearch import com.m3u.core.foundation.util.basic.normalizePlaylistInputForSubmission 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 +153,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, @@ -1048,7 +1051,7 @@ internal class PlaylistRepositoryImpl @Inject constructor( val pinnedCategories = playlist?.pinnedCategories ?: emptyList() val hiddenCategories = playlist?.hiddenCategories ?: emptyList() channelDao - .getCategoriesByPlaylistUrl(url, query) + .getCategoriesByPlaylistUrl(url, query.normalizeForSearch()) .filterNot { it in hiddenCategories } .sortedByDescending { it in pinnedCategories } } @@ -1061,7 +1064,7 @@ internal class PlaylistRepositoryImpl @Inject constructor( val pinnedCategories = playlist.pinnedCategories val hiddenCategories = playlist.hiddenCategories channelDao - .observeCategoriesByPlaylistUrl(playlist.url, query) + .observeCategoriesByPlaylistUrl(playlist.url, query.normalizeForSearch()) .map { categories -> categories .filterNot { it in hiddenCategories } @@ -1113,6 +1116,10 @@ internal class PlaylistRepositoryImpl @Inject constructor( val current = database.withTransaction { target?.also { channelDao.deleteByPlaylistUrl(it.url) + // Explicit, because this table no longer cascades: + // cascading also fired on the INSERT OR REPLACE a + // refresh performs, and emptied the cache with it. + channelDetailsDao.deleteByPlaylistUrl(it.url) playlistDao.delete(it) } } diff --git a/data/src/main/java/com/m3u/data/worker/ChannelDetailsWorker.kt b/data/src/main/java/com/m3u/data/worker/ChannelDetailsWorker.kt new file mode 100644 index 000000000..86d82698a --- /dev/null +++ b/data/src/main/java/com/m3u/data/worker/ChannelDetailsWorker.kt @@ -0,0 +1,145 @@ +package com.m3u.data.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.m3u.data.database.dao.ChannelDao +import com.m3u.data.database.dao.PlaylistDao +import com.m3u.data.database.model.isSeries +import com.m3u.data.database.model.isVod +import com.m3u.data.repository.channel.ChannelDetailsRepository +import com.m3u.data.service.PlayerManager +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Collects the descriptions of a whole catalogue, slowly and on purpose. + * + * Searching by actor needs every title fetched, and Xtream gives them one + * request at a time: some twenty-eight thousand of them here. Two constraints + * shape the whole thing. + * + * These accounts commonly allow **a single connection**, shared with playback. + * So the sweep stops the moment something starts playing and hands the + * connection back — collecting metadata must never cost the viewer their + * stream. It also paces itself between requests instead of going as fast as + * the panel allows. + * + * Progress lives in the database rather than in the worker: each batch asks + * for channels that still have no description, so an interrupted run resumes + * by simply asking again. Nothing to persist, nothing to lose. + */ +@HiltWorker +class ChannelDetailsWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val channelDao: ChannelDao, + private val playlistDao: PlaylistDao, + private val channelDetailsRepository: ChannelDetailsRepository, + private val playerManager: PlayerManager, +) : CoroutineWorker(context, params) { + private val timber = Timber.tag("ChannelDetailsWorker") + + override suspend fun doWork(): Result { + // Every eligible playlist is walked by this one worker rather than by + // one worker each. Live channels carry no description; only films and + // series do. + val playlistUrls = playlistDao.getAll() + .filter { playlist -> playlist.isVod || playlist.isSeries } + .map { playlist -> playlist.url } + if (playlistUrls.isEmpty()) return Result.success() + + var fetched = 0 + try { + for (playlistUrl in playlistUrls) { + while (true) { + if (playerManager.isPlaying.value) { + // Someone is watching. Retry rather than fail: + // WorkManager backs off and returns once the box is + // idle again. + timber.d("playback in progress, yielding the connection") + return Result.retry() + } + val pending = channelDao.getWithoutDetails(playlistUrl, BATCH_SIZE) + if (pending.isEmpty()) break + for (channel in pending) { + if (isStopped) return Result.retry() + if (playerManager.isPlaying.value) return Result.retry() + channelDetailsRepository.fetchIfMissing(channel) + fetched++ + delay(REQUEST_INTERVAL_MILLIS) + } + } + } + timber.d("catalogue complete, $fetched fetched this run") + return Result.success() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + timber.w(e, "sweep interrupted after $fetched") + return Result.retry() + } + } + + companion object { + private const val BATCH_SIZE = 50 + + /** + * Deliberately unhurried. Descriptions are worth nothing on a schedule, + * and a burst of thousands of requests is what a panel reads as abuse. + * + * The pause only holds if a single sweep is running — measured: one + * worker per playlist doubled the rate to some 247 titles a minute, + * which is why [WORK_NAME] is global rather than per playlist. + */ + private const val REQUEST_INTERVAL_MILLIS = 250L + + private const val WORK_NAME = "channel-details-sweep" + + /** + * How often the sweep comes back for another go. + * + * Periodic rather than a single long run, for two reasons. The platform + * stops any worker after about ten minutes, so one run was never going + * to walk a catalogue this size anyway. And playback interrupts the + * sweep on purpose — without something bringing it back, a single + * evening of watching would leave it stopped for good. + */ + private val SWEEP_INTERVAL = 30L to TimeUnit.MINUTES + + fun enqueue(workManager: WorkManager) { + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + // Keep, not update: re-enqueueing on every app start must not + // reset the schedule of a sweep already making its way through. + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder( + SWEEP_INTERVAL.first, + SWEEP_INTERVAL.second, + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria(BackoffPolicy.LINEAR, 5, TimeUnit.MINUTES) + .build() + ) + } + + fun cancel(workManager: WorkManager) { + workManager.cancelUniqueWork(WORK_NAME) + } + } +} diff --git a/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt b/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt index ea07d1865..f496367f2 100644 --- a/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt +++ b/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt @@ -203,6 +203,10 @@ class SubscriptionWorker @AssistedInject constructor( createN10nBuilder() .setContentText(findCompleteContentText(total)) .buildThenNotify() + // The catalogue is in; collect the descriptions behind + // it. That sweep paces itself and steps aside as soon + // as anything plays, so it can start straight away. + ChannelDetailsWorker.enqueue(workManager) Result.success() } catch (cancelled: CancellationException) { throw cancelled 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) + } +} 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…