diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json new file mode 100644 index 00000000..3e874e4e --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json @@ -0,0 +1,834 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "b297c087485321c3ee8282ad1ff675a0", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "SeriesTrackChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentType", + "columnName": "parentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackFlags", + "columnName": "trackFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "relativeTrackIndex", + "columnName": "relativeTrackIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "audioChannels", + "columnName": "audioChannels", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "parentId", + "type" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "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, 'b297c087485321c3ee8282ad1ff675a0')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 6a691723..d390a4b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice import com.github.damontecres.wholphin.ui.components.ViewOptions import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind @@ -48,8 +49,9 @@ import java.util.UUID SeerrServer::class, SeerrUser::class, RememberedTab::class, + SeriesTrackChoice::class, ], - version = 35, + version = 40, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -68,6 +70,7 @@ import java.util.UUID AutoMigration(32, 33), AutoMigration(33, 34), AutoMigration(34, 35), + AutoMigration(35, 40), ], ) @TypeConverters(Converters::class) @@ -87,6 +90,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun playbackEffectDao(): PlaybackEffectDao abstract fun rememberedTabDao(): RememberedTabDao + + abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index c3afbc33..702ecfdb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.data import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemTrackModification -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RelativeMediaStream +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext @@ -28,7 +31,7 @@ class ItemPlaybackRepository constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, private val streamChoiceService: StreamChoiceService, ) { /** @@ -45,54 +48,79 @@ class ItemPlaybackRepository ): ChosenStreams? = serverRepository.currentUser?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) - Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}") - return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) + return getChosenItemFromPlayback(item, itemPlayback, prefs) } + suspend fun getChosenItemFromPlayback( + item: BaseItem, + itemPlayback: ItemPlayback?, + prefs: UserPreferences, + ): ChosenStreams? { + val audioStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.AUDIO, + ) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) + return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs) + } + /** * Get the chosen source & stream for the given item * - * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null + * This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null */ fun getChosenItemFromPlayback( item: BaseItem, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + audioStc: List, + subtitleStc: List, prefs: UserPreferences, ): ChosenStreams? { + Timber.v( + "For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s", + item.id, + itemPlayback != null, + audioStc.isNotEmpty(), + subtitleStc.isNotEmpty(), + ) val source = item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId } ?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty()) if (source != null) { - val audioStream = + val audioResult = streamChoiceService.chooseAudioStream( candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } .orEmpty(), itemPlayback = itemPlayback, - playbackLanguageChoice = plc, + stc = audioStc, prefs = prefs, ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStreamLang = audioStream?.language, + audioStreamLang = audioResult.stream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty(), itemPlayback = itemPlayback, - playbackLanguageChoice = plc, + stc = subtitleStc, prefs = prefs, ) return ChosenStreams( itemPlayback = itemPlayback, - plc = plc, + audioStc = audioStc, + subtitleStc = subtitleStc, itemId = item.id, source = source, videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }, - audioStream = audioStream, + audioStream = audioResult, subtitleStream = subtitleStream, subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED, ) @@ -156,26 +184,36 @@ class ItemPlaybackRepository Timber.v("Saving track selection %s", toSave) toSave = saveItemPlayback(toSave) val seriesId = item.data.seriesId - if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { + if (seriesId != null || item.data.parentId != null) { if (type == MediaStreamType.AUDIO) { - val stream = source.mediaStreams?.first { it.index == trackIndex } + val stream = + RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) if (stream?.language != null) { - streamChoiceService.updateAudio(item.data, stream.language!!) + streamChoiceService.saveSeriesTrackChoice( + item.data, + stream.type, + stream, + ) } } else if (type == MediaStreamType.SUBTITLE) { if (trackIndex == TrackIndex.DISABLED) { - streamChoiceService.updateSubtitles( + streamChoiceService.saveDisabledSeriesTrackChoice( item.data, - subtitleLang = null, - subtitlesDisabled = true, + MediaStreamType.SUBTITLE, + ) + } else if (trackIndex == TrackIndex.ONLY_FORCED) { + streamChoiceService.saveOnlyForcedSeriesTrackChoice( + item.data, + MediaStreamType.SUBTITLE, ) } else { - val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } + val stream = + RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) if (stream?.language != null) { - streamChoiceService.updateSubtitles( + streamChoiceService.saveSeriesTrackChoice( item.data, - stream.language!!, - subtitlesDisabled = false, + stream.type, + stream, ) } } @@ -240,9 +278,10 @@ class ItemPlaybackRepository */ suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { Timber.d("deleteChosenStreams: %s", chosenStreams) - chosenStreams?.plc?.let { - Timber.d("Deleting %s", it) - playbackLanguageChoiceDao.delete(it) + val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty() + if (stc.isNotEmpty()) { + Timber.d("Deleting %s", stc) + seriesTrackChoiceDao.delete(stc) } chosenStreams?.itemPlayback?.let { Timber.d("Deleting %s", it) @@ -256,11 +295,12 @@ class ItemPlaybackRepository */ data class ChosenStreams( val itemPlayback: ItemPlayback?, - val plc: PlaybackLanguageChoice?, + val audioStc: List, + val subtitleStc: List, val itemId: UUID, val source: MediaSourceInfo, val videoStream: MediaStream?, - val audioStream: MediaStream?, - val subtitleStream: MediaStream?, + val audioStream: StreamChoiceResult, + val subtitleStream: StreamChoiceResult, val subtitlesDisabled: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt index 5f8c16e6..06835381 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt @@ -8,17 +8,27 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import java.util.UUID +@Deprecated("Use SeriesTrackChoiceDao") @Dao interface PlaybackLanguageChoiceDao { + @Deprecated("Use SeriesTrackChoiceDao") @Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId") suspend fun get( userId: Int, seriesId: UUID, ): PlaybackLanguageChoice? + @Deprecated("Use SeriesTrackChoiceDao") @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(plc: PlaybackLanguageChoice): Long + @Deprecated("Use SeriesTrackChoiceDao") @Delete fun delete(plc: PlaybackLanguageChoice) + + @Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset") + suspend fun getAll( + limit: Int, + offset: Int, + ): List } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt new file mode 100644 index 00000000..b3126723 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import java.util.UUID + +@Dao +interface SeriesTrackChoiceDao { + @Query( + "SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC", + ) + suspend fun get( + userId: Int, + seasonId: UUID, + seriesId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId") + suspend fun getBySeasonId( + userId: Int, + seasonId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId") + suspend fun getBySeriesId( + userId: Int, + seriesId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(stc: SeriesTrackChoice): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(stc: List): List + + @Delete + suspend fun delete(stc: List) + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId") + suspend fun getAll(userId: Int): List + + @Query("SELECT count(*) from SeriesTrackChoice") + suspend fun count(): Int +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt index fb54dfcd..f5507a50 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt @@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID /** + * DEPRECATED + * * Stores the language choices for a series so they can be applied automatically to other episodes * without the user needing to explicitly choose the tracks + * + * @see SeriesTrackChoice */ +@Deprecated("Use SeriesTrackChoice") @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt new file mode 100644 index 00000000..cd2fece7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt @@ -0,0 +1,61 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType + +/** + * A [MediaStream] with its relative track index, its position within a specific stream type + */ +data class RelativeMediaStream( + val relativeIndex: Int, + val stream: MediaStream, +) { + val type: MediaStreamType get() = stream.type + val language: String? get() = stream.language + val isForced: Boolean get() = stream.isForced + val isExternal: Boolean get() = stream.isExternal + val isDefault: Boolean get() = stream.isDefault + val isHearingImpaired: Boolean get() = stream.isHearingImpaired + + companion object { + fun getRelativeIndex( + index: Int, + streams: List, + ): Int? { + val stream = streams.firstOrNull { it.index == index } ?: return null + return getRelativeIndex(stream, streams) + } + + fun getRelativeIndex( + stream: MediaStream, + streams: List, + ): Int? = + streams + .filter { it.type == stream.type && it.isExternal == stream.isExternal } + .sortedBy { it.index } + .indexOfFirstOrNull { it.index == stream.index } + + fun from( + index: Int, + streams: List, + ): RelativeMediaStream? { + val stream = streams.firstOrNull { it.index == index } ?: return null + val relativeIndex = getRelativeIndex(stream, streams) ?: return null + return RelativeMediaStream(relativeIndex, stream) + } + } +} + +/** + * Maps a list of [MediaStream] into [RelativeMediaStream] + */ +fun List.asRelative(): List { + val map = this.groupBy { Pair(it.type, it.isExternal) } + return map.values + .flatMap { typedStreams -> + typedStreams.sortedBy { it.index }.mapIndexed { index, stream -> + RelativeMediaStream(index, stream) + } + }.sortedBy { it.stream.index } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt new file mode 100644 index 00000000..5dd313d6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt @@ -0,0 +1,199 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import com.github.damontecres.wholphin.services.isSigns +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID + +/** + * Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks + * + * Additional information about the chosen track is stored to compare against tracks in other media + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["userId", "parentId", "type"], +) +@Serializable +data class SeriesTrackChoice( + val userId: Int, + /** + * Season or Series + */ + val parentId: UUID, + /** + * Type of track, ie audio or subtitle + */ + val type: SeriesTrackChoiceType, + /** + * Origin of the [parentId] + */ + val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN, + val itemId: UUID? = null, + val language: String? = null, + val activation: ActivationFlag = ActivationFlag.ACTIVATED, + /** + * Flags for the track such as external or forced + * + * @see [TrackFlag] + */ + val trackFlags: Int = 0, + val codec: String? = null, + /** + * The track index based on its position within all streams of the same type + * + * @see [RelativeMediaStream] + */ + val relativeTrackIndex: Int? = null, + val title: String? = null, + val audioChannels: Int? = null, +) { + constructor( + userId: Int, + itemId: UUID, + parentId: UUID, + type: SeriesTrackChoiceType, + parentType: TrackChoiceParentType, + stream: RelativeMediaStream?, + activation: ActivationFlag, + ) : this( + userId = userId, + parentId = parentId, + parentType = parentType, + type = type, + itemId = itemId, + language = stream?.language, + activation = activation, + trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0, + codec = stream?.stream?.codec, + relativeTrackIndex = stream?.relativeIndex, + title = stream?.stream?.title, + audioChannels = stream?.stream?.channels, + ) + + companion object { + /** + * Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s + */ + fun from(plc: PlaybackLanguageChoice): List = + buildList { + if (plc.subtitleLanguage != null) { + add( + SeriesTrackChoice( + userId = plc.userId, + parentId = plc.seriesId, + parentType = TrackChoiceParentType.DEPRECATED, + type = SeriesTrackChoiceType.SUBTITLE, + itemId = plc.itemId, + language = plc.subtitleLanguage, + activation = if (plc.subtitlesDisabled == true) ActivationFlag.DISABLED else ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ), + ) + } + if (plc.audioLanguage != null) { + add( + SeriesTrackChoice( + userId = plc.userId, + parentId = plc.seriesId, + parentType = TrackChoiceParentType.DEPRECATED, + type = SeriesTrackChoiceType.AUDIO, + itemId = plc.itemId, + language = plc.audioLanguage, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ), + ) + } + } + } +} + +enum class SeriesTrackChoiceType( + val mediaStreamType: MediaStreamType, +) { + AUDIO(MediaStreamType.AUDIO), + SUBTITLE(MediaStreamType.SUBTITLE), +} + +/** + * Flags for a track such as forced or defect + */ +enum class TrackFlag( + val flag: Int, + val hasFlag: (MediaStream) -> Boolean, +) { + DEFAULT(flag = 1, hasFlag = { it.isDefault }), + FORCED(flag = 2, hasFlag = { it.isForced }), + SDH(flag = 4, hasFlag = { it.isHearingImpaired }), + EXTERNAL(flag = 8, hasFlag = { it.isExternal }), + SIGNS(flag = 16, hasFlag = { isSigns(it) }), + ; + + fun within(flag: Int) = flag and this.flag == this.flag + + companion object { + fun SeriesTrackChoice.has(flag: TrackFlag) = flag.within(trackFlags) + + fun Iterable.calculateFlag() = fold(0) { flag, trackFlag -> flag or trackFlag.flag } + + fun calculateTrackFlags(track: MediaStream): Int = TrackFlag.entries.filter { it.hasFlag.invoke(track) }.calculateFlag() + } +} + +/** + * How was the track activated + */ +enum class ActivationFlag { + ACTIVATED, + DISABLED, + ONLY_FORCED, +} + +/** + * Where the [SeriesTrackChoice.parentId] comes from + */ +enum class TrackChoiceParentType { + /** + * Parent ID is from the series + */ + SERIES, + + /** + * Parent ID is from the season + */ + SEASON, + + /** + * Parent ID is from the deprecated [PlaybackLanguageChoice] + */ + DEPRECATED, + + /** + * Parent ID origin is unknown + */ + UNKNOWN, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 6b8dbc98..17312378 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -9,10 +9,13 @@ import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.RememberedTab +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreference @@ -61,6 +64,8 @@ class AppUpgradeHandler private val serverDao: JellyfinServerDao, private val rememberedTabDao: RememberedTabDao, private val mostRecentServerProvider: MostRecentServerProvider, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) @@ -485,5 +490,21 @@ class AppUpgradeHandler } } } + + if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) { + val count = seriesTrackChoiceDao.count() + if (count == 0) { + var offset = 0 + while (true) { + val plcs = playbackLanguageChoiceDao.getAll(100, offset) + if (plcs.isEmpty()) break + offset += plcs.size + + val stcs = plcs.flatMap(SeriesTrackChoice::from) + Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size) + seriesTrackChoiceDao.save(stcs) + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 09450b34..0b9da9f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -1,15 +1,27 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ActivationFlag import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RelativeMediaStream +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.TrackChoiceParentType +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.data.model.asRelative import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -30,50 +42,69 @@ class StreamChoiceService @Inject constructor( private val serverRepository: ServerRepository, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration - suspend fun updateAudio( + suspend fun saveSeriesTrackChoice( dto: BaseItemDto, - audioLang: String, - ) = update(dto) { - it.copy( - audioLanguage = audioLang, - ) - } - - suspend fun updateSubtitles( - dto: BaseItemDto, - subtitleLang: String?, - subtitlesDisabled: Boolean, - ) = update(dto) { - it.copy( - subtitleLanguage = if (subtitlesDisabled) null else subtitleLang, - subtitlesDisabled = subtitlesDisabled, - ) - } - - suspend fun update( - dto: BaseItemDto, - update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice, + type: MediaStreamType, + stream: RelativeMediaStream?, + activation: ActivationFlag = ActivationFlag.ACTIVATED, ) { - val seriesId = dto.seriesId - if (seriesId != null) { - val userId = serverRepository.currentUser!!.rowId - val currentPlc = - playbackLanguageChoiceDao.get(userId, seriesId) - ?: PlaybackLanguageChoice(userId, seriesId, dto.id) - val newPlc = update.invoke(currentPlc) - Timber.v("Saving series PLC: %s", newPlc) - playbackLanguageChoiceDao.save(newPlc) - } + val type = + when (stream?.type ?: type) { + MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO + MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE + else -> return + } + val userId = serverRepository.currentUser!!.rowId + val newStc = + buildList { + dto.parentId + ?.let { seasonId -> + SeriesTrackChoice( + userId = userId, + itemId = dto.id, + parentId = seasonId, + type = type, + parentType = TrackChoiceParentType.SEASON, + stream = stream, + activation = activation, + ) + }?.let(::add) + dto.seriesId + ?.let { seriesId -> + SeriesTrackChoice( + userId = userId, + itemId = dto.id, + parentId = seriesId, + type = type, + parentType = TrackChoiceParentType.SERIES, + stream = stream, + activation = activation, + ) + }?.let(::add) + } + Timber.d( + "Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s", + dto.id, + dto.seriesId, + dto.parentId, + newStc, + ) + seriesTrackChoiceDao.save(newStc) } - suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) = - dto.seriesId?.let { - playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it) - } + suspend fun saveDisabledSeriesTrackChoice( + dto: BaseItemDto, + type: MediaStreamType, + ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED) + + suspend fun saveOnlyForcedSeriesTrackChoice( + dto: BaseItemDto, + type: MediaStreamType, + ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED) /** * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution @@ -104,21 +135,15 @@ class StreamChoiceService */ suspend fun chooseAudioStream( source: MediaSourceInfo, - seriesId: UUID?, + item: BaseItemDto, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + stc: List?, prefs: UserPreferences, - ): MediaStream? { - val plc = - plc ?: seriesId?.let { - playbackLanguageChoiceDao.get( - serverRepository.currentUser!!.rowId, - it, - ) - } + ): StreamChoiceResult? { + val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.AUDIO } - chooseAudioStream(candidates, itemPlayback, plc, prefs) + chooseAudioStream(candidates, itemPlayback, stc, prefs) } } @@ -128,59 +153,70 @@ class StreamChoiceService fun chooseAudioStream( candidates: List, itemPlayback: ItemPlayback?, - playbackLanguageChoice: PlaybackLanguageChoice?, + stc: List, prefs: UserPreferences, - ): MediaStream? = + ): StreamChoiceResult = if (itemPlayback?.audioIndexEnabled == true) { - candidates.firstOrNull { it.index == itemPlayback.audioIndex } - } else { - val seriesLang = - playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } - // If the user has chosen a different language for the series, prefer that - val audioLanguage = - seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) - if (audioLanguage.isNotNullOrBlank()) { - val sorted = - candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) - sorted.firstOrNull { it.language == audioLanguage && it.isDefault } - ?: sorted.firstOrNull { it.language == audioLanguage } - ?: sorted.firstOrNull { it.isDefault } - ?: sorted.firstOrNull() + candidates.firstOrNull { it.index == itemPlayback.audioIndex } with + StreamChoiceReason.Item( + itemPlayback, + ) + } else if (stc.isNotEmpty()) { + val first = stc.first() + val result = scoreStreams(candidates, first) + if (result == null) { + null with StreamChoiceReason.from(first) + } else if (result.isEmpty() && stc.size > 1) { + // SeriesTrackChoice did not apply to any streams, but there are more options + chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs) + } else if (result.isEmpty()) { + // SeriesTrackChoice did not apply to any streams, so use regular selection logic + Timber.v("No audio STC applied") + chooseAudioStream(candidates, itemPlayback, emptyList(), prefs) } else { - candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull() + // Otherwise, use the best scored stream + Timber.v("Using audio STC from %s", first.parentId) + result.first().stream with StreamChoiceReason.from(first, result) } + } else { + val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) + val stream = + if (audioLanguage.isNotNullOrBlank()) { + val sorted = + candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + sorted.firstOrNull { it.language == audioLanguage && it.isDefault } + ?: sorted.firstOrNull { it.language == audioLanguage } + ?: sorted.firstOrNull { it.isDefault } + ?: sorted.firstOrNull() + } else { + candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull() + } + stream with StreamChoiceReason.UserPreferences } /** * Returns the subtitle stream that should play */ suspend fun chooseSubtitleStream( + item: BaseItemDto, source: MediaSourceInfo, audioStream: MediaStream?, - seriesId: UUID?, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + stc: List?, prefs: UserPreferences, - ): MediaStream? { - val plc = - plc ?: seriesId?.let { - playbackLanguageChoiceDao.get( - serverRepository.currentUser!!.rowId, - it, - ) - } - return source.mediaStreams?.letNotEmpty { streams -> + ): StreamChoiceResult? = + source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } + val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) chooseSubtitleStream( audioStream?.language, candidates, itemPlayback, - plc, + stc, prefs, ) } - } /** * Resolves ONLY_FORCED to an actual subtitle track index. @@ -189,7 +225,7 @@ class StreamChoiceService suspend fun resolveSubtitleIndex( source: MediaSourceInfo, audioStreamIndex: Int?, - seriesId: UUID?, + item: BaseItemDto, subtitleIndex: Int, prefs: UserPreferences, ): Int? = @@ -203,17 +239,17 @@ class StreamChoiceService val itemPlayback = ItemPlayback( userId = serverRepository.currentUser!!.rowId, - itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution + itemId = item.id, subtitleIndex = TrackIndex.ONLY_FORCED, ) chooseSubtitleStream( + item = item, source = source, audioStream = audioStream, - seriesId = seriesId, itemPlayback = itemPlayback, - plc = null, + stc = null, prefs = prefs, - )?.index + )?.stream?.index } /** @@ -223,47 +259,81 @@ class StreamChoiceService audioStreamLang: String?, candidates: List, itemPlayback: ItemPlayback?, - playbackLanguageChoice: PlaybackLanguageChoice?, + stc: List, prefs: UserPreferences, - ): MediaStream? { - if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { - return null - } - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) - if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { - // Client-side manual override: User selected "Only Forced" in player menu - return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) - } else if (itemPlayback?.subtitleIndexEnabled == true) { - return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } + ): StreamChoiceResult { + if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) { + if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) { + return null with StreamChoiceReason.Item(itemPlayback) + } else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + // Client-side manual override: User selected "Only Forced" in player menu + return findForcedTrack( + candidates, + subtitleLanguage, + audioStreamLang, + ) with StreamChoiceReason.Item(itemPlayback) + } else { + return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with + StreamChoiceReason.Item(itemPlayback) + } + } else if (stc.isNotEmpty()) { + val first = stc.first() + return when (first.activation) { + ActivationFlag.DISABLED -> { + null with StreamChoiceReason.from(first) + } + + ActivationFlag.ONLY_FORCED -> { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + val stream = + findForcedTrack( + candidates, + subtitleLanguage, + audioStreamLang, + ) + if (stream != null) { + stream with StreamChoiceReason.from(first) + } else { + handleSubtitleStreamChoiceResult( + result = emptyList(), + audioStreamLang = audioStreamLang, + candidates = candidates, + itemPlayback = itemPlayback, + stc = stc, + prefs = prefs, + ) + } + } + + ActivationFlag.ACTIVATED -> { + val result = + scoreStreams(candidates, first) + ?: throw IllegalStateException("scoreStreams should not return null for activated") + handleSubtitleStreamChoiceResult( + result = result, + audioStreamLang = audioStreamLang, + candidates = candidates, + itemPlayback = itemPlayback, + stc = stc, + prefs = prefs, + ) + } + } } else { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) val subtitleMode = - when { - playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { - // User has chosen a series level subtitle language, so override their normal - // subtitle mode to display that language - SubtitlePlaybackMode.ALWAYS - } - - playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> { - // Series level settings disables subtitles - SubtitlePlaybackMode.NONE - } - - else -> { - // Fallback to the user's preference - when (prefs.userPreferences?.subtitleMode) { - SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode - SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT - SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART - SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED - SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS - SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE - null -> SubtitlePlaybackMode.DEFAULT - } - } + when (prefs.userPreferences?.subtitleMode) { + SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode + SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT + SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART + SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED + SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS + SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE + null -> SubtitlePlaybackMode.DEFAULT } ?: SubtitlePlaybackMode.DEFAULT val candidates = candidates @@ -277,73 +347,104 @@ class StreamChoiceService }.thenByDescending { it.isForced && it.language.isUnknown } .thenByDescending { it.isForced }, ) - return when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { - // Prefer non-forced first - !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) - } ?: candidates.firstOrNull { - it.language.equalsLangOrUnknown(subtitleLanguage) - } - } else { - candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() - } - } - - SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } - ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } - } else { - candidates.firstOrNull { it.isForced } - } - } - - SubtitlePlaybackMode.SMART -> { - if (subtitleLanguage.isNotNullOrBlank()) { - val audioLanguage = userConfig?.audioLanguagePreference - if ( - // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different - (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || - // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio - (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) - ) { - candidates.firstOrNull { it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.language.isUnknown } + val stream = + when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { + // Prefer non-forced first + !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) + } ?: candidates.firstOrNull { + it.language.equalsLangOrUnknown(subtitleLanguage) + } } else { - // Otherwise, show forced subtitles in preferred lang - candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() } - } else { - candidates.firstOrNull { it.isDefault } + } + + SubtitlePlaybackMode.ONLY_FORCED -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } + } else { + candidates.firstOrNull { it.isForced } + } + } + + SubtitlePlaybackMode.SMART -> { + if (subtitleLanguage.isNotNullOrBlank()) { + val audioLanguage = userConfig?.audioLanguagePreference + if ( + // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different + (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || + // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio + (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) + ) { + candidates.firstOrNull { it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.language.isUnknown } + } else { + // Otherwise, show forced subtitles in preferred lang + candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + } + } else { + candidates.firstOrNull { it.isDefault } + } + } + + SubtitlePlaybackMode.DEFAULT -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } + ?: candidates.firstOrNull { it.isDefault || it.isForced } + } else { + candidates.firstOrNull { it.isDefault || it.isForced } + } + } + + SubtitlePlaybackMode.NONE -> { + null } } - - SubtitlePlaybackMode.DEFAULT -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } - ?: candidates.firstOrNull { it.isDefault || it.isForced } - } else { - candidates.firstOrNull { it.isDefault || it.isForced } - } - } - - SubtitlePlaybackMode.NONE -> { - null - } - } + return stream with StreamChoiceReason.UserPreferences } } - /** Returns true if the track is forced (via metadata flag or title patterns). */ - private fun isForcedOrSigns(track: MediaStream): Boolean { - if (track.isForced) return true - val title = track.title ?: track.displayTitle ?: return false - return title.contains("forced", ignoreCase = true) || - title.contains("signs", ignoreCase = true) || - title.contains("songs", ignoreCase = true) + /** + * Recursively calls [chooseSubtitleStream] based on [result] and [stc] + */ + private fun handleSubtitleStreamChoiceResult( + result: List, + audioStreamLang: String?, + candidates: List, + itemPlayback: ItemPlayback?, + stc: List, + prefs: UserPreferences, + ): StreamChoiceResult { + val first = stc.first() + return if (result.isEmpty() && stc.size > 1) { + // SeriesTrackChoice did not apply to any streams, but there are more options + chooseSubtitleStream( + audioStreamLang, + candidates, + itemPlayback, + stc.subList(1, stc.size), + prefs, + ) + } else if (result.isEmpty()) { + // SeriesTrackChoice did not apply to any streams, so use regular selection logic + Timber.v("No subtitle STC applied") + chooseSubtitleStream( + audioStreamLang, + candidates, + itemPlayback, + emptyList(), + prefs, + ) + } else { + // Otherwise, use the best scored stream + Timber.v("Using subtitle STC from %s", first.parentId) + result.first().stream with StreamChoiceReason.from(first, result) + } } /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ @@ -367,19 +468,92 @@ class StreamChoiceService // 3. Unknown language forced track return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } } + + suspend fun getSeriesTrackChoices( + seriesId: UUID?, + seasonId: UUID?, + type: SeriesTrackChoiceType, + ): List { + val userId = serverRepository.currentUser?.rowId ?: return emptyList() + return when { + seriesId != null && seasonId != null -> { + seriesTrackChoiceDao.get( + userId = userId, + seasonId = seasonId, + seriesId = seriesId, + type = type, + ) + } + + seriesId != null -> { + seriesTrackChoiceDao.getBySeriesId( + userId = userId, + seriesId = seriesId, + type = type, + ) + } + + seasonId != null -> { + seriesTrackChoiceDao.getBySeasonId( + userId = userId, + seasonId = seasonId, + type = type, + ) + } + + else -> { + emptyList() + } + } + } + + suspend fun getSeriesTrackChoices( + item: BaseItemDto, + type: SeriesTrackChoiceType, + ): List = getSeriesTrackChoices(item.seriesId, item.parentId, type) + + suspend fun getSeriesTrackChoices( + item: BaseItemDto, + type: MediaStreamType, + ): List = + getSeriesTrackChoices( + seriesId = item.seriesId, + seasonId = item.parentId, + type = + when (type) { + MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO + MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE + else -> return emptyList() + }, + ) } private val String?.isUnknown: Boolean get() = - this == null || + this.isNullOrBlank() || this.equals("unknown", true) || this.equals("und", true) || this.equals("undetermined", true) || this.equals("mul", true) || this.equals("zxx", true) +private val String?.isNotUnknown: Boolean + get() = !this.isUnknown + private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown +private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true) + +/** Returns true if the track is forced (via metadata flag or title patterns). */ +fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track) + +fun isSigns(track: MediaStream): Boolean { + val title = track.title ?: track.displayTitle ?: return false + return title.contains("forced", ignoreCase = true) || + title.contains("signs", ignoreCase = true) || + title.contains("songs", ignoreCase = true) +} + /** * Based on the user's preferences, get their preferred language for audio or subtitles * @@ -412,3 +586,153 @@ fun getPreferredLanguage( else -> pref }?.takeIf { it.isNotNullOrBlank() } } + +/** + * Scores streams based the user's explicit [SeriesTrackChoice] + * + * @return sorted list of preferred streams or null if stream should be disabled + */ +fun scoreStreams( + streams: List, + choice: SeriesTrackChoice, +): List? { + if (choice.activation == ActivationFlag.DISABLED) { + return null + } + val streams = + streams.filter { + if ( + (choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) && + !isForcedOrSigns(it) + ) { + // Filter out non-forced tracks if the user only wants forced + return@filter false + } + if (choice.language.isNotUnknown && + !choice.language.equalsLangExact(it.language) + ) { + // Filter out languages that do not match + return@filter false + } + // Filter by type + choice.type.mediaStreamType == it.type + } + val scored = + streams + .asRelative() + .map { s -> + var score = 0 + + // Prefer exact language match over unknown + if (choice.language.equalsLangExact(s.language)) score += 10_000 + + if (s.isForced && choice.has(TrackFlag.FORCED)) { + score += 1_000 + } + if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) { + score += 500 + } + if (s.isDefault && choice.has(TrackFlag.DEFAULT)) { + score += 500 + } + + if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) { + score += 500 + } else if (choice.has(TrackFlag.SDH) && s.isForced) { + // If user wants SDH, prefer non-forced "regular" over forced + score -= 10 + } + + if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) { + score += 100 + } + + if (s.relativeIndex == choice.relativeTrackIndex) { + score += 100 + } + if (s.stream.title == choice.title) { + score += 10 + } + + if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) { + score += 100 + } else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) { + score += 50 + } + + if (s.isDefault) { + // All else being equal, prefer the default track + score += 1 + } + ScoredMediaStream(score, s.stream) + }.sortedByDescending { it.score } + return scored +} + +data class StreamChoiceResult( + val stream: MediaStream?, + val reason: StreamChoiceReason, +) + +data class ScoredMediaStream( + val score: Int, + val stream: MediaStream, +) + +private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason) + +/** + * Why a particular [MediaStream] was chosen + */ +sealed interface StreamChoiceReason { + val description: StringProvider + val scores: List get() = emptyList() + + data object Unknown : StreamChoiceReason { + override val description: StringProvider = ResStringProvider(R.string.unknown) + } + + data object UserPreferences : StreamChoiceReason { + override val description: StringProvider = + ResStringProvider(R.string.profile_specific_settings) + } + + data class Series( + val stc: SeriesTrackChoice, + override val scores: List, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER) + } + + data class Season( + val stc: SeriesTrackChoice, + override val scores: List, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER) + } + + data class Item( + val itemPlayback: ItemPlayback, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER) + } + + companion object { + fun from( + stc: SeriesTrackChoice, + scores: List = emptyList(), + ) = when (stc.parentType) { + TrackChoiceParentType.SERIES -> Series(stc, scores) + TrackChoiceParentType.SEASON -> Season(stc, scores) + TrackChoiceParentType.DEPRECATED -> Unknown + TrackChoiceParentType.UNKNOWN -> Unknown + } + + private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) } + private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) } + private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 29688664..78270a9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -79,6 +80,10 @@ object DatabaseModule { @Singleton fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides + @Singleton + fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index 10a41af3..f87010d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -209,7 +209,12 @@ fun ContextMenu( favorite = item.favorite, seriesId = item.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, + canClearChosenStreams = + chosenStreams.let { + it?.itemPlayback != null || + it?.audioStc?.isNotEmpty() == true || + it?.subtitleStc?.isNotEmpty() == true + }, contextMenu = contextMenu, actions = actions, onChooseVersion = { @@ -235,11 +240,11 @@ fun ContextMenu( resources = resources, streams = source.mediaStreams.orEmpty(), type = type, - currentIndex = + streamChoice = if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream?.index + chosenStreams?.audioStream } else { - chosenStreams?.subtitleStream?.index + chosenStreams?.subtitleStream }, onClick = { trackIndex -> actions.onChooseTracks.invoke( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index d7bd9951..f1d7555b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -651,12 +652,13 @@ fun resourceFor(type: MediaStreamType): Int = fun chooseStream( resources: Resources, streams: List, - currentIndex: Int?, + streamChoice: StreamChoiceResult?, type: MediaStreamType, preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, ): DialogParams { val filteredStreams = streams.filter { it.type == type } + val currentIndex = streamChoice?.stream?.index return DialogParams( fromLongClick = false, title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), @@ -674,6 +676,16 @@ fun chooseStream( }, supportingContent = { }, + trailingContent = + if (currentIndex == null && streamChoice != null) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) @@ -719,6 +731,16 @@ fun chooseStream( supportingContent = { if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) }, + trailingContent = + if (currentIndex == stream.index) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(stream.index) }, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index 083dded6..94842ef5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -54,8 +54,8 @@ fun VideoStreamDetails( ) = VideoStreamDetails( chosenStreams?.source, chosenStreams?.videoStream, - chosenStreams?.audioStream, - chosenStreams?.subtitleStream, + chosenStreams?.audioStream?.stream, + chosenStreams?.subtitleStream?.stream, numberOfVersions, modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 7c8268b0..b63ae54a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -40,6 +40,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences @@ -56,6 +57,7 @@ import kotlinx.coroutines.withContext import org.acra.util.versionCodeLong import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import java.util.Date @@ -68,6 +70,7 @@ class DebugViewModel @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, + val seriesTrackChoiceDao: SeriesTrackChoiceDao, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, ) : ViewModel() { @@ -169,6 +172,13 @@ class DebugViewModel itemPlaybacks.value = results } } + viewModelScope.launchIO { + serverRepository.currentUser?.rowId?.let { + val results = seriesTrackChoiceDao.getAll(it) + Timber.v("Got %s SeriesTrackChoice", results.size) + results.forEach { Timber.v("%s", it) } + } + } viewModelScope.launchIO { val logcat = getLogCatLines() this@DebugViewModel.logcat.value = logcat diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index a45fbda0..97aaaf86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -145,11 +145,10 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -163,7 +162,6 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -171,10 +169,7 @@ class EpisodeViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 939d1331..d0fea506 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -223,11 +223,10 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -241,7 +240,6 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -249,10 +247,7 @@ class MovieViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 2b92f71b..568cece8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -561,11 +561,10 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -579,7 +578,6 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -587,10 +585,7 @@ class SeriesViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 871264c2..697e2e05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -158,7 +159,11 @@ class PlayExternalViewModel is PlaylistItem.Media -> playlistItem.item } val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig) - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) if (mediaSource == null) { Timber.w("Media source is null") return@launchDefault @@ -168,13 +173,14 @@ class PlayExternalViewModel val subtitleIndex = streamChoiceService .chooseSubtitleStream( + item = item.data, source = mediaSource, audioStream = null, - seriesId = item.data.seriesId, itemPlayback = playbackConfig, - plc = plc, + stc = subtitleStc, prefs = prefs, - )?.index + )?.stream + ?.index val externalSubtitles = mediaSource.mediaStreams ?.filter { it.isExternal } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 6ee345f5..f89f3af1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AppPreference @@ -490,7 +491,16 @@ class PlaybackViewModel null } - val plc = streamChoiceService.getPlaybackLanguageChoice(base) + val audioStc = + streamChoiceService.getSeriesTrackChoices( + base, + SeriesTrackChoiceType.AUDIO, + ) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + base, + SeriesTrackChoiceType.AUDIO, + ) if (mediaSource == null && !isLiveTv) { showToast( @@ -519,30 +529,31 @@ class PlaybackViewModel val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() - val audioStream = + val audioResult = mediaSource?.let { streamChoiceService .chooseAudioStream( source = mediaSource, - seriesId = base.seriesId, + item = base, itemPlayback = itemPlayback, - plc = plc, + stc = audioStc, prefs = preferences, ) } - val audioIndex = audioStream?.index + val audioIndex = audioResult?.stream?.index val subtitleIndex = mediaSource?.let { streamChoiceService .chooseSubtitleStream( + item = base, source = mediaSource, - audioStream = audioStream, - seriesId = base.seriesId, + audioStream = audioResult?.stream, itemPlayback = itemPlayback, - plc = plc, + stc = subtitleStc, prefs = preferences, - )?.index + )?.stream + ?.index } Timber.d( "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", @@ -956,7 +967,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = currentPlayback.mediaSourceInfo, audioStreamIndex = index, - seriesId = currentItem.item.data.seriesId, + item = currentItem.item.data, subtitleIndex = currentPlayback.subtitleIndex, prefs = preferences, ) @@ -992,7 +1003,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = currentPlayback.mediaSourceInfo, audioStreamIndex = currentPlayback.audioIndex, - seriesId = currentItem.item.data.seriesId, + item = currentItem.item.data, subtitleIndex = index, prefs = preferences, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index e74ffdf2..7e742235 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -20,6 +20,8 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.launchIO @@ -229,12 +231,21 @@ class SlideshowViewModel source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } ChosenStreams( itemPlayback = null, - plc = null, + audioStc = emptyList(), + subtitleStc = emptyList(), itemId = image.id, source = source, videoStream = video, - audioStream = audio, - subtitleStream = null, + audioStream = + StreamChoiceResult( + audio, + StreamChoiceReason.Unknown, + ), + subtitleStream = + StreamChoiceResult( + null, + StreamChoiceReason.Unknown, + ), subtitlesDisabled = false, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81353ff3..a46bf080 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -964,4 +964,7 @@ %1$d Episode %1$d Episodes + Chosen by %1$s + User + Series diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt new file mode 100644 index 00000000..283ee8ca --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.assertNotNull +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelativeMediaStreamTest { + @Test + fun `Test getRelativeIndex`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(0, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(0, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + } + + @Test + fun `Test getRelativeIndex for stream`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + } + + @Test + fun `Test asRelative basic`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(5, streams.size) + + val relative = streams.asRelative() + // Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + + // Audio + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Video + assertEquals(0, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + } + + @Test + fun `Test asRelative with external`() { + val source = + TestTracks + .Builder() + .addExternalSubtitle(2) + .addSubtitle(2) + .addAudio(2) + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(6, streams.size) + + val relative = streams.asRelative() + // External Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + + // Subtitle + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Audio + assertEquals(0, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + assertEquals(1, relative[5].relativeIndex) + assertEquals(5, relative[5].stream.index) + } + + @Test + fun `Test asRelative with external mixed`() { + val source = + TestTracks + .Builder() + .addExternalSubtitle(1) + .addSubtitle(1) + .addAudio(1) + .addExternalSubtitle(1) + .addAudio(1) + .addSubtitle(1) + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(6, streams.size) + + val relative = streams.asRelative() + // External Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Subtitle + assertEquals(0, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + assertEquals(1, relative[5].relativeIndex) + assertEquals(5, relative[5].stream.index) + + // Audio + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index b300e09a..d8304897 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -1,10 +1,12 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ActivationFlag import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences @@ -523,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced( ), streamAudioLang = "eng", ), + TestInput( + 0, + SubtitlePlaybackMode.NONE, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + null, + SubtitlePlaybackMode.NONE, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), ) } } @@ -885,7 +935,7 @@ data class TestInput( val streamAudioLang: String? = "eng", val subtitles: List, val itemPlayback: ItemPlayback? = null, - val plc: PlaybackLanguageChoice? = null, + val plc: List = emptyList(), val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE, val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE, val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, @@ -895,7 +945,7 @@ data class TestInput( private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" -private fun serverRepo( +fun mockServerRepo( audioLang: String?, subtitleMode: SubtitlePlaybackMode?, subtitleLang: String?, @@ -918,15 +968,15 @@ private fun serverRepo( private fun runTest(input: TestInput) { val service = StreamChoiceService( - serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), - mockk(), + mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), + mockk(), ) val result = service.chooseSubtitleStream( audioStreamLang = input.streamAudioLang, candidates = input.subtitles, itemPlayback = input.itemPlayback, - playbackLanguageChoice = input.plc, + stc = input.plc, prefs = UserPreferences( AppPreferences.getDefaultInstance(), @@ -937,7 +987,7 @@ private fun runTest(input: TestInput) { ), ), ) - Assert.assertEquals(input.expectedIndex, result?.index) + Assert.assertEquals(input.expectedIndex, result.stream?.index) } fun subtitle( @@ -945,6 +995,8 @@ fun subtitle( lang: String?, default: Boolean = false, forced: Boolean = false, + sdh: Boolean = false, + external: Boolean = false, title: String? = null, ): MediaStream = MediaStream( @@ -952,6 +1004,26 @@ fun subtitle( language = lang, isDefault = default, isForced = forced, + isHearingImpaired = sdh, + isInterlaced = false, + index = index, + isExternal = external, + isTextSubtitleStream = true, + supportsExternalStream = true, + title = title, + ) + +fun audio( + index: Int, + lang: String?, + default: Boolean = false, + title: String? = null, +): MediaStream = + MediaStream( + type = MediaStreamType.AUDIO, + language = lang, + isDefault = default, + isForced = false, isHearingImpaired = false, isInterlaced = false, index = index, @@ -978,11 +1050,63 @@ private fun plc( audioLang: String? = null, subtitleLang: String? = null, subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, -): PlaybackLanguageChoice = - PlaybackLanguageChoice( - userId = 1, - seriesId = UUID.randomUUID(), - audioLanguage = audioLang, - subtitleLanguage = subtitleLang, - subtitlesDisabled = subtitlesDisabled, - ) + subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null, +): List = + buildList { + audioLang + ?.let { + SeriesTrackChoice( + userId = 1, + language = audioLang, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ) + }?.let(::add) + + if (subtitleLang != null) { + SeriesTrackChoice( + userId = 1, + language = subtitleLang, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } else if (subtitlesDisabled == true) { + SeriesTrackChoice( + userId = 1, + language = null, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } else if (subtitlesOnlyForced == true) { + SeriesTrackChoice( + userId = 1, + language = null, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt new file mode 100644 index 00000000..e58d8aec --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt @@ -0,0 +1,534 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao +import com.github.damontecres.wholphin.data.model.ActivationFlag +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.TrackChoiceParentType +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.audio +import com.github.damontecres.wholphin.services.mockServerRepo +import com.github.damontecres.wholphin.services.subtitle +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TestSeriesTrackChoice { + private val mockServerRepository = + mockServerRepo( + audioLang = "eng", + subtitleLang = "eng", + subtitleMode = SubtitlePlaybackMode.DEFAULT, + ) + private val mockSeriesTrackChoiceDao = mockk(relaxed = false) + + private val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + private val seriesId = UUID.randomUUID() + private val season1Id = UUID.randomUUID() + private val season2Id = UUID.randomUUID() + private val season3Id = UUID.randomUUID() + + private val user = + JellyfinUser( + 1, + UUID.randomUUID(), + null, + UUID.randomUUID(), + null, + ) + + @Before + fun before() { + every { mockServerRepository.currentUser } returns user + } + + @Test + fun `Test StreamChoiceService call correct dao function`() = + runTest { + val mockSeriesTrackChoiceDao = mockk(relaxed = true) + val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + val seriesId = UUID.randomUUID() + val seasonId = UUID.randomUUID() + + // Has both series & season + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = seriesId, + parentId = seasonId, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + + // Has only series + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = seriesId, + parentId = null, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + + // Has only season + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = null, + parentId = seasonId, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + } + + @Test + fun `Test series disabled`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNull(result.stream) + } + + @Test + fun `Test series only forced`() = + runTest { + val mockServerRepository = + mockServerRepo( + audioLang = "eng", + subtitleLang = "eng", + subtitleMode = SubtitlePlaybackMode.NONE, + ) + val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = + UserPreferences( + AppPreferences.getDefaultInstance(), + JellyfinUserPreferences(), + ), + candidates = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "eng", forced = false), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + ), + ), + ) + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test series disabled with season`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNull(result.stream) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test item playback overrides STC for subtitles`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + subtitleIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Item) + } + + @Test + fun `Test item playback overrides STC for audio`() = + runTest { + val result = + streamChoiceService.chooseAudioStream( + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + audioIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + audio(0, "eng", default = true), + audio(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.AUDIO, + language = "eng", + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.AUDIO, + language = "eng", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Item) + } + + @Test + fun `Test season STC doesn't apply but series STC does - subtitle`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + audioIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + language = "spa", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test season STC doesn't apply but series STC does - audio`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + audio(0, "eng", default = true), + audio(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ONLY_FORCED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ACTIVATED, + language = "spa", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test STC has forced flag`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = listOf(TrackFlag.FORCED).calculateFlag(), + ), + ) + + // Choose forced track + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "spa"), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // No forced track to choose from, so nothing should be chosen + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = false, forced = false), + subtitle(1, "spa"), + ), + stc = stc, + ).let { result -> + assertNull(result.stream) + } + } + + @Test + fun `Test prefer SDH`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = listOf(TrackFlag.SDH).calculateFlag(), + ), + ) + + // Prefer SDH over default and non-SDH + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false, sdh = false), + subtitle(2, "eng", default = false, forced = false, sdh = true), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(2, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // No SDH, so prefer regular over forced + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false, sdh = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + } + + @Test + fun `Test prefer default track with no other flags`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + ), + ) + + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = false), + subtitle(1, "eng", default = false, forced = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // Test out-of-order tracks + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = false, forced = false), + subtitle(1, "eng", default = true, forced = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt new file mode 100644 index 00000000..d2013f19 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.EnumSet + +class TestTrackFlag { + @Test + fun basicTest() { + val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag() + assertTrue(TrackFlag.DEFAULT.within(flag)) + assertTrue(TrackFlag.EXTERNAL.within(flag)) + assertFalse(TrackFlag.FORCED.within(flag)) + assertFalse(TrackFlag.SDH.within(flag)) + } + + @Test + fun none() { + val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag() + assertFalse(TrackFlag.DEFAULT.within(flag)) + assertFalse(TrackFlag.EXTERNAL.within(flag)) + assertFalse(TrackFlag.FORCED.within(flag)) + assertFalse(TrackFlag.SDH.within(flag)) + } + + @Test + fun all() { + val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag() + TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) } + } + + @Test + fun checkFlags() { + TrackFlag.entries.forEach { + assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0) + } + val distinctFlags = TrackFlag.entries.map { it.flag }.distinct() + assertEquals(TrackFlag.entries.size, distinctFlags.size) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 34d962da..c291d067 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -11,7 +11,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair import org.jellyfin.sdk.model.api.UserDto -import org.junit.Assert.assertNotNull import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -149,3 +148,16 @@ inline fun assertIs(obj: Any?) { Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ") } } + +/** + * Asserts that the object is not null + * + * Includes a [contract] to smart-cast the object to T + */ +@OptIn(ExperimentalContracts::class) +fun assertNotNull(obj: Any?) { + contract { + returns() implies (obj != null) + } + org.junit.Assert.assertNotNull(obj) +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 4a463578..b266bb96 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences @@ -266,6 +267,10 @@ object TestDatabaseModule { @Singleton fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides + @Singleton + fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 21c6179b..7189adb1 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences @@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.test.TestTracks @@ -64,6 +67,7 @@ import org.jellyfin.sdk.api.operations.UserLibraryApi import org.jellyfin.sdk.api.operations.VideosApi import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult import org.jellyfin.sdk.model.api.MediaSegmentType @@ -201,7 +205,12 @@ class PlaybackViewModelTests { coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource - coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null + coEvery { + mockStreamChoiceService.getSeriesTrackChoices( + any(), + any(), + ) + } returns emptyList() coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns PlayerCreation(mockPlayer) every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true) @@ -214,18 +223,26 @@ class PlaybackViewModelTests { any(), any(), ) - } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO } + } returns + StreamChoiceResult( + mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }, + StreamChoiceReason.Unknown, + ) coEvery { mockStreamChoiceService.chooseSubtitleStream( + item = any(), source = mediaSource, - any(), - any(), - any(), - any(), - any(), + audioStream = any(), + itemPlayback = any(), + stc = any(), + prefs = any(), + ) + } returns + StreamChoiceResult( + mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }, + StreamChoiceReason.Unknown, ) - } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE } } @OptIn(ExperimentalCoroutinesApi::class)