From 5766c2d571af0c4ca5d723b3c0f0624267bb1cb7 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:41:20 +0200 Subject: [PATCH 1/5] feat(search): match titles regardless of accents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "prenom" returned nothing while "prénom" found "Le Prénom". SQLite's LIKE ignores case for ASCII but never diacritics, so 4 882 of the 40 971 titles in a French catalogue — 12 % — were unreachable to anyone who did not type the accent, which on a TV remote is most of the time. Titles are now stored a second time in a folded form (NFD, combining marks dropped, lowercased with Locale.ROOT) and queries are folded the same way before they reach the DAO. The comparison stays symmetric: accented or not, either side matches. normalizeForSearch is the single definition of that fold. The migration backfills existing rows through it in Kotlin rather than through a stack of SQL REPLACE calls, so the two can never drift — and a drift here is silent, rows simply stop matching. titleNormalized sits outside the constructor on purpose: a data class only copies constructor parameters, so copy(title = …) recomputes it instead of carrying a stale value that would drop the channel out of every search. Folding happens in the repositories rather than at each call site, so every screen searching channels or categories behaves the same way. No index on the column: '%query%' cannot use one, and it would only slow the bulk inserts a resubscription performs on tens of thousands of rows. Verified on a 40 971-row database: migration to v27 completed at startup, every row backfilled, and "prenom" and "AMELIE" now return the accented titles on screen. Co-Authored-By: Claude Opus 5 (1M context) --- .../m3u/core/foundation/util/basic/Strings.kt | 28 +- .../util/basic/NormalizeForSearchTest.kt | 45 + .../com.m3u.data.database.M3UDatabase/27.json | 840 ++++++++++++++++++ .../m3u/data/database/Migration26To27Test.kt | 130 +++ .../m3u/data/database/DatabaseMigrations.kt | 48 + .../com/m3u/data/database/DatabaseModule.kt | 1 + .../java/com/m3u/data/database/M3UDatabase.kt | 2 +- .../com/m3u/data/database/dao/ChannelDao.kt | 33 +- .../com/m3u/data/database/model/Channel.kt | 19 + .../channel/ChannelRepositoryImpl.kt | 24 +- .../playlist/PlaylistRepositoryImpl.kt | 5 +- 11 files changed, 1153 insertions(+), 22 deletions(-) create mode 100644 core/foundation/src/test/java/com/m3u/core/foundation/util/basic/NormalizeForSearchTest.kt create mode 100644 data/schemas/com.m3u.data.database.M3UDatabase/27.json create mode 100644 data/src/androidTest/java/com/m3u/data/database/Migration26To27Test.kt 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/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/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..bd3e4ec19 --- /dev/null +++ b/data/src/androidTest/java/com/m3u/data/database/Migration26To27Test.kt @@ -0,0 +1,130 @@ +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) + .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) + .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/main/java/com/m3u/data/database/DatabaseMigrations.kt b/data/src/main/java/com/m3u/data/database/DatabaseMigrations.kt index 99be2a363..82c2fc89c 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,53 @@ 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 + } + } + } + + 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..614b0ee1c 100644 --- a/data/src/main/java/com/m3u/data/database/DatabaseModule.kt +++ b/data/src/main/java/com/m3u/data/database/DatabaseModule.kt @@ -50,6 +50,7 @@ internal object DatabaseModule { .addMigrations(DatabaseMigrations.migration22To23(credentialVault)) .addMigrations(DatabaseMigrations.MIGRATION_24_25) .addMigrations(DatabaseMigrations.MIGRATION_25_26) + .addMigrations(DatabaseMigrations.MIGRATION_26_27) .build() @Provides 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..4d26ee7f9 100644 --- a/data/src/main/java/com/m3u/data/database/M3UDatabase.kt +++ b/data/src/main/java/com/m3u/data/database/M3UDatabase.kt @@ -36,7 +36,7 @@ import com.m3u.data.database.model.ProviderPlaybackSessionEntity ChannelMetadataBase::class, ExtensionChannelMetadataOverlay::class, ], - version = 26, + version = 27, exportSchema = true, autoMigrations = [ AutoMigration( 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..dbe14d854 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 @@ -184,7 +184,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 +197,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 +276,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 @@ -313,7 +321,7 @@ interface ChannelDao { """ SELECT * FROM streams WHERE playlist_url = :url - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' AND `group` = :category """ ) @@ -327,7 +335,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 +350,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 +365,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 +380,7 @@ interface ChannelDao { """ SELECT * FROM streams WHERE playlist_url = :url - AND title LIKE '%'||:query||'%' + AND title_normalized LIKE '%'||:query||'%' """ ) fun pagingAllByPlaylistUrlMixed( @@ -430,10 +438,15 @@ interface ChannelDao { ): Flow + /** + * @param query must already be folded with normalizeForSearch — the column + * it is compared against holds folded titles, 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||'%' + AND title_normalized LIKE '%'||:query||'%' """ ) fun query( @@ -475,7 +488,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/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/repository/channel/ChannelRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/channel/ChannelRepositoryImpl.kt index 3dd17762d..3fdd22769 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,6 +2,7 @@ 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 @@ -31,8 +32,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 +45,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) @@ -151,6 +159,6 @@ internal class ChannelRepositoryImpl @Inject constructor( .catch { emit(emptyList()) } override fun search(query: String): PagingSource { - return channelDao.query(query) + 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..d76fd04b8 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,6 +12,7 @@ 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 @@ -1048,7 +1049,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 +1062,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 } From d02e1c761de0747f43f2f9cf250c080c32918d56 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:52:02 +0200 Subject: [PATCH 2/5] feat(details): show synopsis, cast and rating on long press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-pressing a channel opened a sheet offering Favourite, Hide and Create shortcut, and said nothing about the title itself. Other clients show a synopsis and a cast there. Xtream keeps that apart from the catalogue: get_vod_streams returns names and artwork only, so each description costs a get_vod_info call — which is why it is fetched when the sheet opens rather than up front. Series answer the same shape on get_series_info. Tapping a channel still plays it immediately. The description sits behind a long press so nothing is added between a viewer and their film. Details are cached in channel_details, keyed on (playlist, channel reference) rather than on the channel row id: ids are regenerated on every re-subscription, which is the only way to refresh a catalogue, so keying on them would discard the cache each time. Requests are serialised behind a mutex. These accounts commonly allow a single connection and it is shared with playback — several descriptions fetched at once can cost the viewer their stream. A row is written even when the panel has nothing to say, so "asked, has no description" stays distinguishable from "never asked" and the sheet does not re-ask on every open. The call is issued from this module rather than from the parser submodule, which models the episode list of get_series_info but not the descriptive block. Only its URL builder is reused, so this stays a single-repository change. Panels disagree on their own field types — rating arrives as 5, "5" and 7.4, tmdb_id alternates just as freely, movies say releasedate where series say releaseDate. A lenient reader keeps a sheet from being lost over a field nobody reads as a number; five tests pin those shapes down. Verified on device: long press on a film shows 2017 · Action, Science-Fiction, Thriller ★ 5.6, its cast, its director and its synopsis, and the row lands in the cache with the folded cast ready for search. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ChannelDetailsSection.kt | 136 +++ .../components/ChannelDetailsViewModel.kt | 79 ++ .../ui/material/components/MediaSheet.kt | 9 + data/build.gradle.kts | 1 + .../com.m3u.data.database.M3UDatabase/28.json | 935 ++++++++++++++++++ .../com/m3u/data/database/DatabaseModule.kt | 7 + .../java/com/m3u/data/database/M3UDatabase.kt | 8 +- .../data/database/dao/ChannelDetailsDao.kt | 39 + .../m3u/data/database/model/ChannelDetails.kt | 72 ++ .../parser/xtream/XtreamChannelDetails.kt | 89 ++ .../m3u/data/parser/xtream/XtreamParser.kt | 18 + .../data/parser/xtream/XtreamParserImpl.kt | 60 +- .../m3u/data/repository/RepositoryModule.kt | 8 + .../channel/ChannelDetailsRepository.kt | 33 + .../channel/ChannelDetailsRepositoryImpl.kt | 110 +++ i18n/src/main/res/values/ui.xml | 4 + 16 files changed, 1606 insertions(+), 2 deletions(-) create mode 100644 app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsSection.kt create mode 100644 app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsViewModel.kt create mode 100644 data/schemas/com.m3u.data.database.M3UDatabase/28.json create mode 100644 data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt create mode 100644 data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt create mode 100644 data/src/main/java/com/m3u/data/parser/xtream/XtreamChannelDetails.kt create mode 100644 data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepository.kt create mode 100644 data/src/main/java/com/m3u/data/repository/channel/ChannelDetailsRepositoryImpl.kt diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsSection.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsSection.kt new file mode 100644 index 000000000..e1d5a5c8d --- /dev/null +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsSection.kt @@ -0,0 +1,136 @@ +package com.m3u.smartphone.ui.material.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelDetails +import com.m3u.i18n.R.string +import com.m3u.smartphone.ui.material.model.LocalSpacing + +/** + * Synopsis, cast and rating for the channel a sheet was opened on. + * + * Shows nothing at all — not an error, not a placeholder — when the panel has + * no description to give. Live channels never have one, and a sheet opened to + * hide or favourite a channel should not be pushed around by an empty block. + */ +@Composable +fun ChannelDetailsSection( + channel: Channel?, + modifier: Modifier = Modifier, +) { + val viewModel: ChannelDetailsViewModel = hiltViewModel() + LaunchedEffect(channel?.id) { viewModel.load(channel) } + val state by viewModel.state.collectAsStateWithLifecycle() + val spacing = LocalSpacing.current + + when (val current = state) { + ChannelDetailsState.Absent -> Unit + + ChannelDetailsState.Loading -> { + if (channel == null) return + Text( + text = stringResource(string.ui_details_loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(horizontal = spacing.medium), + ) + } + + is ChannelDetailsState.Content -> Column( + verticalArrangement = Arrangement.spacedBy(spacing.extraSmall), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = spacing.medium), + ) { + current.details.Headline() + current.details.cast?.let { cast -> + LabelledText(label = stringResource(string.ui_details_cast), value = cast) + } + current.details.director?.let { director -> + LabelledText(label = stringResource(string.ui_details_director), value = director) + } + current.details.plot?.let { plot -> + Text( + text = plot, + style = MaterialTheme.typography.bodyMedium, + // Long enough to be worth reading, short enough to leave + // the actions below reachable without scrolling. + maxLines = 6, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = spacing.extraSmall), + ) + } + HorizontalDivider(modifier = Modifier.padding(top = spacing.small)) + } + } +} + +/** Year, genre and rating on one line — the things read at a glance. */ +@Composable +private fun ChannelDetails.Headline() { + val spacing = LocalSpacing.current + val year = releaseDate?.take(4)?.takeIf { it.length == 4 && it.all(Char::isDigit) } + val summary = listOfNotNull(year, genre).joinToString(" · ") + if (summary.isBlank() && rating == null) return + Row( + horizontalArrangement = Arrangement.spacedBy(spacing.small), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (summary.isNotBlank()) { + Text( + text = summary, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + rating?.let { rating -> + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = rating, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun LabelledText(label: String, value: String) { + Text( + text = "$label: $value", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsViewModel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsViewModel.kt new file mode 100644 index 000000000..e8056ae60 --- /dev/null +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ChannelDetailsViewModel.kt @@ -0,0 +1,79 @@ +package com.m3u.smartphone.ui.material.components + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.ChannelDetails +import com.m3u.data.repository.channel.ChannelDetailsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +sealed interface ChannelDetailsState { + /** Nothing to show and nothing on its way — live channels, mostly. */ + data object Absent : ChannelDetailsState + + data object Loading : ChannelDetailsState + + data class Content(val details: ChannelDetails) : ChannelDetailsState +} + +/** + * Holds the description shown when a channel sheet opens. + * + * Cached values appear immediately; anything missing is fetched once, in the + * background, and lands through the database rather than being pushed here — + * so a sheet closed mid-request still keeps what it paid for. + */ +@HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) +class ChannelDetailsViewModel @Inject constructor( + private val repository: ChannelDetailsRepository, +) : ViewModel() { + private val channel = MutableStateFlow(null) + private var fetching: Job? = null + + val state: StateFlow = channel + .flatMapLatest { current -> + if (current == null) flowOf(null) else repository.observe(current) + } + .map { details -> + when { + details == null -> ChannelDetailsState.Loading + details.isEmpty -> ChannelDetailsState.Absent + else -> ChannelDetailsState.Content(details) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChannelDetailsState.Loading) + + fun load(channel: Channel?) { + if (this.channel.value?.id == channel?.id) return + this.channel.value = channel + fetching?.cancel() + channel ?: return + fetching = viewModelScope.launch { repository.fetchIfMissing(channel) } + } +} + +/** + * A row the panel answered for but had nothing to say about. Kept in the + * database so it is not asked again, shown as nothing at all. + */ +private val ChannelDetails.isEmpty: Boolean + get() = plot.isNullOrBlank() && + cast.isNullOrBlank() && + director.isNullOrBlank() && + genre.isNullOrBlank() && + rating.isNullOrBlank() && + releaseDate.isNullOrBlank() diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/MediaSheet.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/MediaSheet.kt index 42455638e..32402921c 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/MediaSheet.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/MediaSheet.kt @@ -94,6 +94,15 @@ fun MediaSheet( verticalArrangement = Arrangement.spacedBy(spacing.small), modifier = Modifier.padding(spacing.medium) ) { + // Above the actions, so the description is what a long press + // shows first. Renders nothing when the channel has none. + ChannelDetailsSection( + channel = when (value) { + is MediaSheetValue.PlaylistScreen -> value.channel + is MediaSheetValue.FavoriteScreen -> value.channel + is MediaSheetValue.ForyouScreen -> null + } + ) when (value) { is MediaSheetValue.ForyouScreen -> { value.playlist?.let { playlist -> diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 103778ad6..df4c13b52 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -169,6 +169,7 @@ dependencies { implementation(libs.jakewharton.disklrucache) + testImplementation(kotlin("test-junit")) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.core) diff --git a/data/schemas/com.m3u.data.database.M3UDatabase/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/src/main/java/com/m3u/data/database/DatabaseModule.kt b/data/src/main/java/com/m3u/data/database/DatabaseModule.kt index 614b0ee1c..0c7a81729 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 @@ -59,6 +60,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 4d26ee7f9..58cdbfcad 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 = 27, + version = 28, 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/ChannelDetailsDao.kt b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt new file mode 100644 index 000000000..8d8211ed1 --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt @@ -0,0 +1,39 @@ +package com.m3u.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.m3u.data.database.model.ChannelDetails +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChannelDetailsDao { + @Upsert + suspend fun upsert(details: ChannelDetails) + + @Query( + """ + SELECT * FROM channel_details + WHERE playlist_url = :playlistUrl AND channel_reference = :channelReference + """ + ) + suspend fun get(playlistUrl: String, channelReference: String): ChannelDetails? + + @Query( + """ + SELECT * FROM channel_details + WHERE playlist_url = :playlistUrl AND channel_reference = :channelReference + """ + ) + fun observe(playlistUrl: String, channelReference: String): Flow + + /** + * References already fetched for a playlist, so the background sweep can + * skip them without asking for each one in turn. + */ + @Query("SELECT channel_reference FROM channel_details WHERE playlist_url = :playlistUrl") + suspend fun getFetchedReferences(playlistUrl: String): List + + @Query("SELECT COUNT(*) FROM channel_details WHERE playlist_url = :playlistUrl") + suspend fun countByPlaylistUrl(playlistUrl: String): Int +} diff --git a/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt new file mode 100644 index 000000000..0c1a5b90f --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt @@ -0,0 +1,72 @@ +package com.m3u.data.database.model + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * Synopsis, cast and rating for one title, kept so the panel is asked once. + * + * Keyed on (playlist, channel reference) rather than on the channel row id. + * Ids are regenerated whenever a playlist is re-subscribed — which is the only + * way to refresh a catalogue — so keying on them would throw the whole cache + * away every time, and re-earning it costs one request per title. + */ +@Entity( + tableName = "channel_details", + primaryKeys = ["playlist_url", "channel_reference"], + foreignKeys = [ + ForeignKey( + entity = Playlist::class, + parentColumns = ["url"], + childColumns = ["playlist_url"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("playlist_url"), + ], +) +@Immutable +data class ChannelDetails( + @ColumnInfo(name = "playlist_url") + val playlistUrl: String, + @ColumnInfo(name = "channel_reference") + val channelReference: String, + @ColumnInfo(name = "plot") + val plot: String? = null, + /** + * ⚠️ `cast` is an SQL keyword. Room quotes it in everything it generates, + * but any hand-written query naming this column must quote it too. + */ + @ColumnInfo(name = "cast") + val cast: String? = null, + /** + * [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/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/i18n/src/main/res/values/ui.xml b/i18n/src/main/res/values/ui.xml index 24210636f..b6319e6a6 100644 --- a/i18n/src/main/res/values/ui.xml +++ b/i18n/src/main/res/values/ui.xml @@ -116,4 +116,8 @@ %1$d days Never + + Cast + Director + Loading details… From 15b4acbe784e9e39a5d3fc617326935deebd65d4 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:02:29 +0200 Subject: [PATCH 3/5] feat(search): find titles by their cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching an actor returned nothing: cast lists were fetched for the detail sheet but never looked at by search. The query now left-joins channel_details, so a catalogue whose descriptions have not been collected yet searches exactly as before — the actor half simply matches nothing until they arrive. Results carry why they matched. A search for "Gyllenhaal" returns films whose titles share nothing with the query, and a bare list of them reads as a bug, so the matching cast is shown under the title. ChannelDetailsWorker collects the rest of the catalogue in the background. Two things shape it: - These accounts commonly allow a single connection, shared with playback. The sweep steps aside the moment something starts playing and paces itself between requests rather than going as fast as the panel allows. Collecting metadata should not cost a viewer their stream. - Progress lives in the database, not in the worker: each batch asks for channels that still have no description, so an interrupted run resumes by asking again. Nothing to persist, nothing to lose — which matters when twenty-eight thousand titles are spread over several nights. It starts after a subscription completes, when the catalogue is freshly in and the descriptions behind it are missing. Work names are hashed rather than raw: a playlist URL carries the account credentials in its query string and work names surface in diagnostics. Verified on device with 400 titles collected: "gyllenhaal" returns three films — Spirit, Night Call, In the Grey — none of which carry the name in their title, each showing the cast that matched. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/m3u/smartphone/ui/AppViewModel.kt | 7 +- .../playlist/components/ChannelGallery.kt | 1 + .../playlist/components/ChannelItem.kt | 14 ++ .../business/playlist/PlaylistViewModel.kt | 5 + .../com/m3u/data/database/dao/ChannelDao.kt | 56 +++++++- .../database/model/ChannelSearchResult.kt | 22 +++ .../repository/channel/ChannelRepository.kt | 3 +- .../channel/ChannelRepositoryImpl.kt | 3 +- .../m3u/data/worker/ChannelDetailsWorker.kt | 129 ++++++++++++++++++ .../com/m3u/data/worker/SubscriptionWorker.kt | 4 + 10 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 data/src/main/java/com/m3u/data/database/model/ChannelSearchResult.kt create mode 100644 data/src/main/java/com/m3u/data/worker/ChannelDetailsWorker.kt 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..4cf47d4a5 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 @@ -79,10 +79,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, ) } } 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/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/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt b/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt index dbe14d854..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 @@ -296,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 @@ -439,19 +463,39 @@ interface ChannelDao { /** - * @param query must already be folded with normalizeForSearch — the column - * it is compared against holds folded titles, so an unfolded query would - * match nothing as soon as it carried an accent or a capital. + * 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_normalized 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( """ 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/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 3fdd22769..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 @@ -8,6 +8,7 @@ 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 @@ -158,7 +159,7 @@ internal class ChannelRepositoryImpl @Inject constructor( override fun observeAllHidden(): Flow> = channelDao.observeAllHidden() .catch { emit(emptyList()) } - override fun search(query: String): PagingSource { + override fun search(query: String): PagingSource { return channelDao.query(query.normalizeForSearch()) } } 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..0016a5f4c --- /dev/null +++ b/data/src/main/java/com/m3u/data/worker/ChannelDetailsWorker.kt @@ -0,0 +1,129 @@ +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.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +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 { + val playlistUrl = inputData.getString(INPUT_STRING_PLAYLIST_URL) ?: return Result.failure() + val playlist = playlistDao.get(playlistUrl) ?: return Result.failure() + // Live channels carry no description; only films and series do. + if (!playlist.isVod && !playlist.isSeries) return Result.success() + + var fetched = 0 + try { + while (true) { + if (playerManager.isPlaying.value) { + // Someone is watching. Retry rather than fail: WorkManager + // backs off and comes back when 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()) { + timber.d("catalogue complete, $fetched fetched this run") + return Result.success() + } + for (channel in pending) { + if (isStopped) return Result.retry() + if (playerManager.isPlaying.value) return Result.retry() + channelDetailsRepository.fetchIfMissing(channel) + fetched++ + delay(REQUEST_INTERVAL_MILLIS) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + timber.w(e, "sweep interrupted after $fetched") + return Result.retry() + } + } + + companion object { + private const val INPUT_STRING_PLAYLIST_URL = "playlist-url" + 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. + */ + private const val REQUEST_INTERVAL_MILLIS = 250L + + fun enqueue(workManager: WorkManager, playlistUrl: String) { + workManager.enqueueUniqueWork( + workName(playlistUrl), + // Keep, not replace: re-enqueueing must not restart a sweep + // that is already making its way through the catalogue. + ExistingWorkPolicy.KEEP, + OneTimeWorkRequestBuilder() + .setInputData(workDataOf(INPUT_STRING_PLAYLIST_URL to playlistUrl)) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria(BackoffPolicy.LINEAR, 5, TimeUnit.MINUTES) + .addTag(playlistWorkTag(playlistUrl)) + .build() + ) + } + + fun cancel(workManager: WorkManager, playlistUrl: String) { + workManager.cancelUniqueWork(workName(playlistUrl)) + } + + // Hashed, never raw: a playlist URL carries the account credentials in + // its query string, and work names surface in diagnostics. + private fun workName(playlistUrl: String): String = + hashedWorkTag(namespace = "channel-details", value = playlistUrl) + } +} 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..f60ae1def 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. + url?.let { ChannelDetailsWorker.enqueue(workManager, it) } Result.success() } catch (cancelled: CancellationException) { throw cancelled From 1febe18da76c071d4fcc2b9a9666a003b872f6ba Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:39:50 +0200 Subject: [PATCH 4/5] fix(details): keep cached descriptions across a catalogue refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-subscribing is the only way to refresh an Xtream catalogue, and it writes the playlist row back with INSERT OR REPLACE. SQLite performs a REPLACE as a delete followed by an insert, so the ON DELETE CASCADE on channel_details fired on every refresh. Measured on a real database: 401 rows before, 0 after — and each row costs one network request to earn back, so the table emptied itself precisely when it was most expensive to refill. The foreign key is gone; rows are deleted explicitly where a playlist is genuinely unsubscribed, next to where its channels are. Migration 28 to 29 rebuilds the table without the constraint, keeping what is already cached. The sweep is now one worker for all playlists instead of one per playlist. Two of them running side by side halved the pause between requests — measured at some 247 titles a minute against the 130 intended. The mutex still kept requests strictly serialised, so the single-connection limit was never breached, but the pacing was not what it claimed to be. It is also periodic rather than a single long run. The platform stops any worker after about ten minutes, so one run was never going to walk twenty-eight thousand titles; and since playback deliberately interrupts the sweep, without something bringing it back an evening of watching would leave it stopped for good. The existing migration tests were fixed too: they only supplied migrations up to 26, so raising the schema silently broke all six. They now cover the whole path, and a new test pins down the behaviour this commit is about — cached rows surviving the INSERT OR REPLACE a refresh performs. Verified on device: schema at 29, cache intact through the migration, one worker, 130 titles a minute, and a search for "judor" returning Roulez jeunesse, Les Nouvelles Aventures d'Aladin, H and Platane. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/m3u/smartphone/ui/AppViewModel.kt | 19 + .../com.m3u.data.database.M3UDatabase/29.json | 922 ++++++++++++++++++ .../m3u/data/database/Migration21To22Test.kt | 2 + .../m3u/data/database/Migration22To23Test.kt | 4 + .../m3u/data/database/Migration23To24Test.kt | 2 + .../m3u/data/database/Migration24To25Test.kt | 2 + .../m3u/data/database/Migration25To26Test.kt | 2 + .../m3u/data/database/Migration26To27Test.kt | 2 + .../m3u/data/database/Migration28To29Test.kt | 97 ++ .../PlaylistRepositoryProviderRestoreTest.kt | 8 + .../m3u/data/database/DatabaseMigrations.kt | 51 + .../com/m3u/data/database/DatabaseModule.kt | 1 + .../java/com/m3u/data/database/M3UDatabase.kt | 2 +- .../data/database/dao/ChannelDetailsDao.kt | 10 + .../m3u/data/database/model/ChannelDetails.kt | 22 +- .../playlist/PlaylistRepositoryImpl.kt | 6 + .../m3u/data/worker/ChannelDetailsWorker.kt | 100 +- .../com/m3u/data/worker/SubscriptionWorker.kt | 2 +- 18 files changed, 1200 insertions(+), 54 deletions(-) create mode 100644 data/schemas/com.m3u.data.database.M3UDatabase/29.json create mode 100644 data/src/androidTest/java/com/m3u/data/database/Migration28To29Test.kt 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 4cf47d4a5..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 @@ -115,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() @@ -137,6 +155,7 @@ class AppViewModel @Inject constructor( init { refreshProgrammes() + collectChannelDetails() tvRepository.connected .onEach { timber.d("connected tv changed: $it") 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 index bd3e4ec19..30eefdbeb 100644 --- a/data/src/androidTest/java/com/m3u/data/database/Migration26To27Test.kt +++ b/data/src/androidTest/java/com/m3u/data/database/Migration26To27Test.kt @@ -44,6 +44,7 @@ class Migration26To27Test { ) .allowMainThreadQueries() .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() val migrated = database.openHelper.writableDatabase @@ -81,6 +82,7 @@ class Migration26To27Test { ) .allowMainThreadQueries() .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/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 82c2fc89c..4e8681908 100644 --- a/data/src/main/java/com/m3u/data/database/DatabaseMigrations.kt +++ b/data/src/main/java/com/m3u/data/database/DatabaseMigrations.kt @@ -529,6 +529,57 @@ internal object DatabaseMigrations { } } + /** + * 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() { 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 0c7a81729..938ba89ea 100644 --- a/data/src/main/java/com/m3u/data/database/DatabaseModule.kt +++ b/data/src/main/java/com/m3u/data/database/DatabaseModule.kt @@ -52,6 +52,7 @@ internal object DatabaseModule { .addMigrations(DatabaseMigrations.MIGRATION_24_25) .addMigrations(DatabaseMigrations.MIGRATION_25_26) .addMigrations(DatabaseMigrations.MIGRATION_26_27) + .addMigrations(DatabaseMigrations.MIGRATION_28_29) .build() @Provides 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 58cdbfcad..e8859f434 100644 --- a/data/src/main/java/com/m3u/data/database/M3UDatabase.kt +++ b/data/src/main/java/com/m3u/data/database/M3UDatabase.kt @@ -39,7 +39,7 @@ import com.m3u.data.database.model.ProviderPlaybackSessionEntity ExtensionChannelMetadataOverlay::class, ChannelDetails::class, ], - version = 28, + version = 29, exportSchema = true, autoMigrations = [ AutoMigration( diff --git a/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt index 8d8211ed1..8d960147d 100644 --- a/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt +++ b/data/src/main/java/com/m3u/data/database/dao/ChannelDetailsDao.kt @@ -36,4 +36,14 @@ interface ChannelDetailsDao { @Query("SELECT COUNT(*) FROM channel_details WHERE playlist_url = :playlistUrl") suspend fun countByPlaylistUrl(playlistUrl: String): Int + + /** + * Called when a playlist is unsubscribed for good. + * + * This 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/ChannelDetails.kt b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt index 0c1a5b90f..47dfdc7aa 100644 --- a/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt +++ b/data/src/main/java/com/m3u/data/database/model/ChannelDetails.kt @@ -3,7 +3,6 @@ package com.m3u.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.ForeignKey import androidx.room.Index /** @@ -14,18 +13,21 @@ import androidx.room.Index * way to refresh a catalogue — so keying on them would throw the whole cache * away every time, and re-earning it costs one request per title. */ +/* + * Deliberately without a foreign key onto playlists. + * + * Re-subscribing 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"], - foreignKeys = [ - ForeignKey( - entity = Playlist::class, - parentColumns = ["url"], - childColumns = ["playlist_url"], - onUpdate = ForeignKey.CASCADE, - onDelete = ForeignKey.CASCADE, - ) - ], indices = [ Index("playlist_url"), ], diff --git a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt index d76fd04b8..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 @@ -18,6 +18,7 @@ import com.m3u.core.foundation.util.basic.startsWithAny import com.m3u.data.api.OkhttpClient import com.m3u.data.database.M3UDatabase import com.m3u.data.database.dao.ChannelDao +import com.m3u.data.database.dao.ChannelDetailsDao import com.m3u.data.database.dao.PlaylistDao import com.m3u.data.database.dao.ProgrammeDao import com.m3u.data.database.dao.ProviderDao @@ -152,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, @@ -1114,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 index 0016a5f4c..86d82698a 100644 --- a/data/src/main/java/com/m3u/data/worker/ChannelDetailsWorker.kt +++ b/data/src/main/java/com/m3u/data/worker/ChannelDetailsWorker.kt @@ -5,12 +5,11 @@ import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import androidx.work.workDataOf import com.m3u.data.database.dao.ChannelDao import com.m3u.data.database.dao.PlaylistDao import com.m3u.data.database.model.isSeries @@ -53,33 +52,38 @@ class ChannelDetailsWorker @AssistedInject constructor( private val timber = Timber.tag("ChannelDetailsWorker") override suspend fun doWork(): Result { - val playlistUrl = inputData.getString(INPUT_STRING_PLAYLIST_URL) ?: return Result.failure() - val playlist = playlistDao.get(playlistUrl) ?: return Result.failure() - // Live channels carry no description; only films and series do. - if (!playlist.isVod && !playlist.isSeries) return Result.success() + // 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 { - while (true) { - if (playerManager.isPlaying.value) { - // Someone is watching. Retry rather than fail: WorkManager - // backs off and comes back when 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()) { - timber.d("catalogue complete, $fetched fetched this run") - return Result.success() - } - for (channel in pending) { - if (isStopped) return Result.retry() - if (playerManager.isPlaying.value) return Result.retry() - channelDetailsRepository.fetchIfMissing(channel) - fetched++ - delay(REQUEST_INTERVAL_MILLIS) + 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) { @@ -89,41 +93,53 @@ class ChannelDetailsWorker @AssistedInject constructor( } companion object { - private const val INPUT_STRING_PLAYLIST_URL = "playlist-url" 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 - fun enqueue(workManager: WorkManager, playlistUrl: String) { - workManager.enqueueUniqueWork( - workName(playlistUrl), - // Keep, not replace: re-enqueueing must not restart a sweep - // that is already making its way through the catalogue. - ExistingWorkPolicy.KEEP, - OneTimeWorkRequestBuilder() - .setInputData(workDataOf(INPUT_STRING_PLAYLIST_URL to playlistUrl)) + 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) - .addTag(playlistWorkTag(playlistUrl)) .build() ) } - fun cancel(workManager: WorkManager, playlistUrl: String) { - workManager.cancelUniqueWork(workName(playlistUrl)) + fun cancel(workManager: WorkManager) { + workManager.cancelUniqueWork(WORK_NAME) } - - // Hashed, never raw: a playlist URL carries the account credentials in - // its query string, and work names surface in diagnostics. - private fun workName(playlistUrl: String): String = - hashedWorkTag(namespace = "channel-details", value = playlistUrl) } } 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 f60ae1def..f496367f2 100644 --- a/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt +++ b/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt @@ -206,7 +206,7 @@ class SubscriptionWorker @AssistedInject constructor( // 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. - url?.let { ChannelDetailsWorker.enqueue(workManager, it) } + ChannelDetailsWorker.enqueue(workManager) Result.success() } catch (cancelled: CancellationException) { throw cancelled From 757c34efbb9b93cfba63cd8f3b6011e2d0b5df16 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:30:33 +0200 Subject: [PATCH 5/5] test(xtream): pin down the detail payload shapes data/.gitignore excluded /src/test, so the module could not carry unit tests at all. The entry is dropped and five come with it. They cover what panels actually send: a rating as a bare number, tmdb_id alternating between number and string, movies spelling releasedate where series spell releaseDate, cast falling back to actors, plot to description, and unknown fields appearing without warning. Any of those refused outright would cost the sheet its content. Co-Authored-By: Claude Opus 5 (1M context) --- data/.gitignore | 1 - .../parser/xtream/XtreamChannelDetailsTest.kt | 112 ++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 data/src/test/java/com/m3u/data/parser/xtream/XtreamChannelDetailsTest.kt diff --git a/data/.gitignore b/data/.gitignore index c8b0aa09c..796b96d1c 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,2 +1 @@ /build -/src/test diff --git a/data/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) + } +}