Merge Wholphin v1.1.0 while preserving Wolfskin features #27
29 changed files with 2780 additions and 286 deletions
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<SeriesTrackChoice>,
|
||||
subtitleStc: List<SeriesTrackChoice>,
|
||||
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<SeriesTrackChoice>,
|
||||
val subtitleStc: List<SeriesTrackChoice>,
|
||||
val itemId: UUID,
|
||||
val source: MediaSourceInfo,
|
||||
val videoStream: MediaStream?,
|
||||
val audioStream: MediaStream?,
|
||||
val subtitleStream: MediaStream?,
|
||||
val audioStream: StreamChoiceResult,
|
||||
val subtitleStream: StreamChoiceResult,
|
||||
val subtitlesDisabled: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<PlaybackLanguageChoice>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SeriesTrackChoice>
|
||||
|
||||
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId")
|
||||
suspend fun getBySeasonId(
|
||||
userId: Int,
|
||||
seasonId: UUID,
|
||||
type: SeriesTrackChoiceType,
|
||||
): List<SeriesTrackChoice>
|
||||
|
||||
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId")
|
||||
suspend fun getBySeriesId(
|
||||
userId: Int,
|
||||
seriesId: UUID,
|
||||
type: SeriesTrackChoiceType,
|
||||
): List<SeriesTrackChoice>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun save(stc: SeriesTrackChoice): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun save(stc: List<SeriesTrackChoice>): List<Long>
|
||||
|
||||
@Delete
|
||||
suspend fun delete(stc: List<SeriesTrackChoice>)
|
||||
|
||||
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId")
|
||||
suspend fun getAll(userId: Int): List<SeriesTrackChoice>
|
||||
|
||||
@Query("SELECT count(*) from SeriesTrackChoice")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<MediaStream>,
|
||||
): Int? {
|
||||
val stream = streams.firstOrNull { it.index == index } ?: return null
|
||||
return getRelativeIndex(stream, streams)
|
||||
}
|
||||
|
||||
fun getRelativeIndex(
|
||||
stream: MediaStream,
|
||||
streams: List<MediaStream>,
|
||||
): 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<MediaStream>,
|
||||
): 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<MediaStream>.asRelative(): List<RelativeMediaStream> {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<SeriesTrackChoice> =
|
||||
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<TrackFlag>.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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SeriesTrackChoice>?,
|
||||
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<MediaStream>,
|
||||
itemPlayback: ItemPlayback?,
|
||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
||||
stc: List<SeriesTrackChoice>,
|
||||
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<MediaStream> { 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<MediaStream> { 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<SeriesTrackChoice>?,
|
||||
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<MediaStream>,
|
||||
itemPlayback: ItemPlayback?,
|
||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
||||
stc: List<SeriesTrackChoice>,
|
||||
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<ScoredMediaStream>,
|
||||
audioStreamLang: String?,
|
||||
candidates: List<MediaStream>,
|
||||
itemPlayback: ItemPlayback?,
|
||||
stc: List<SeriesTrackChoice>,
|
||||
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<SeriesTrackChoice> {
|
||||
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<SeriesTrackChoice> = getSeriesTrackChoices(item.seriesId, item.parentId, type)
|
||||
|
||||
suspend fun getSeriesTrackChoices(
|
||||
item: BaseItemDto,
|
||||
type: MediaStreamType,
|
||||
): List<SeriesTrackChoice> =
|
||||
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<MediaStream>,
|
||||
choice: SeriesTrackChoice,
|
||||
): List<ScoredMediaStream>? {
|
||||
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<ScoredMediaStream> 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<ScoredMediaStream>,
|
||||
) : StreamChoiceReason {
|
||||
override val description: StringProvider =
|
||||
ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER)
|
||||
}
|
||||
|
||||
data class Season(
|
||||
val stc: SeriesTrackChoice,
|
||||
override val scores: List<ScoredMediaStream>,
|
||||
) : 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<ScoredMediaStream> = 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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<MediaStream>,
|
||||
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) },
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ fun VideoStreamDetails(
|
|||
) = VideoStreamDetails(
|
||||
chosenStreams?.source,
|
||||
chosenStreams?.videoStream,
|
||||
chosenStreams?.audioStream,
|
||||
chosenStreams?.subtitleStream,
|
||||
chosenStreams?.audioStream?.stream,
|
||||
chosenStreams?.subtitleStream?.stream,
|
||||
numberOfVersions,
|
||||
modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -964,4 +964,7 @@
|
|||
<item quantity="one">%1$d Episode</item>
|
||||
<item quantity="other">%1$d Episodes</item>
|
||||
</plurals>
|
||||
<string name="chosen_by">Chosen by %1$s</string>
|
||||
<string name="user">User</string>
|
||||
<string name="series">Series</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MediaStream>,
|
||||
val itemPlayback: ItemPlayback? = null,
|
||||
val plc: PlaybackLanguageChoice? = null,
|
||||
val plc: List<SeriesTrackChoice> = 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<PlaybackLanguageChoiceDao>(),
|
||||
mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
|
||||
mockk<SeriesTrackChoiceDao>(),
|
||||
)
|
||||
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<SeriesTrackChoice> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SeriesTrackChoiceDao>(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<SeriesTrackChoiceDao>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <reified T : Any> 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<BaseItemDto>(),
|
||||
any<SeriesTrackChoiceType>(),
|
||||
)
|
||||
} 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue