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.RememberedTab
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
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 com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
|
@ -48,8 +49,9 @@ import java.util.UUID
|
||||||
SeerrServer::class,
|
SeerrServer::class,
|
||||||
SeerrUser::class,
|
SeerrUser::class,
|
||||||
RememberedTab::class,
|
RememberedTab::class,
|
||||||
|
SeriesTrackChoice::class,
|
||||||
],
|
],
|
||||||
version = 35,
|
version = 40,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(3, 4),
|
AutoMigration(3, 4),
|
||||||
|
|
@ -68,6 +70,7 @@ import java.util.UUID
|
||||||
AutoMigration(32, 33),
|
AutoMigration(32, 33),
|
||||||
AutoMigration(33, 34),
|
AutoMigration(33, 34),
|
||||||
AutoMigration(34, 35),
|
AutoMigration(34, 35),
|
||||||
|
AutoMigration(35, 40),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
@ -87,6 +90,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun playbackEffectDao(): PlaybackEffectDao
|
abstract fun playbackEffectDao(): PlaybackEffectDao
|
||||||
|
|
||||||
abstract fun rememberedTabDao(): RememberedTabDao
|
abstract fun rememberedTabDao(): RememberedTabDao
|
||||||
|
|
||||||
|
abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class Converters {
|
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.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
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.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
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.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -28,7 +31,7 @@ class ItemPlaybackRepository
|
||||||
constructor(
|
constructor(
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
val itemPlaybackDao: ItemPlaybackDao,
|
||||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||||
private val streamChoiceService: StreamChoiceService,
|
private val streamChoiceService: StreamChoiceService,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,54 +48,79 @@ class ItemPlaybackRepository
|
||||||
): ChosenStreams? =
|
): ChosenStreams? =
|
||||||
serverRepository.currentUser?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
return getChosenItemFromPlayback(item, itemPlayback, prefs)
|
||||||
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
|
|
||||||
return getChosenItemFromPlayback(item, itemPlayback, plc, 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
|
* 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(
|
fun getChosenItemFromPlayback(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
plc: PlaybackLanguageChoice?,
|
audioStc: List<SeriesTrackChoice>,
|
||||||
|
subtitleStc: List<SeriesTrackChoice>,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): ChosenStreams? {
|
): ChosenStreams? {
|
||||||
|
Timber.v(
|
||||||
|
"For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s",
|
||||||
|
item.id,
|
||||||
|
itemPlayback != null,
|
||||||
|
audioStc.isNotEmpty(),
|
||||||
|
subtitleStc.isNotEmpty(),
|
||||||
|
)
|
||||||
val source =
|
val source =
|
||||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
|
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
|
||||||
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
|
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
val audioStream =
|
val audioResult =
|
||||||
streamChoiceService.chooseAudioStream(
|
streamChoiceService.chooseAudioStream(
|
||||||
candidates =
|
candidates =
|
||||||
source.mediaStreams
|
source.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.AUDIO }
|
?.filter { it.type == MediaStreamType.AUDIO }
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
playbackLanguageChoice = plc,
|
stc = audioStc,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
)
|
)
|
||||||
val subtitleStream =
|
val subtitleStream =
|
||||||
streamChoiceService.chooseSubtitleStream(
|
streamChoiceService.chooseSubtitleStream(
|
||||||
audioStreamLang = audioStream?.language,
|
audioStreamLang = audioResult.stream?.language,
|
||||||
candidates =
|
candidates =
|
||||||
source.mediaStreams
|
source.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
playbackLanguageChoice = plc,
|
stc = subtitleStc,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
)
|
)
|
||||||
return ChosenStreams(
|
return ChosenStreams(
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
plc = plc,
|
audioStc = audioStc,
|
||||||
|
subtitleStc = subtitleStc,
|
||||||
itemId = item.id,
|
itemId = item.id,
|
||||||
source = source,
|
source = source,
|
||||||
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
|
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
|
||||||
audioStream = audioStream,
|
audioStream = audioResult,
|
||||||
subtitleStream = subtitleStream,
|
subtitleStream = subtitleStream,
|
||||||
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
|
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
|
||||||
)
|
)
|
||||||
|
|
@ -156,26 +184,36 @@ class ItemPlaybackRepository
|
||||||
Timber.v("Saving track selection %s", toSave)
|
Timber.v("Saving track selection %s", toSave)
|
||||||
toSave = saveItemPlayback(toSave)
|
toSave = saveItemPlayback(toSave)
|
||||||
val seriesId = item.data.seriesId
|
val seriesId = item.data.seriesId
|
||||||
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
|
if (seriesId != null || item.data.parentId != null) {
|
||||||
if (type == MediaStreamType.AUDIO) {
|
if (type == MediaStreamType.AUDIO) {
|
||||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
val stream =
|
||||||
|
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
|
||||||
if (stream?.language != null) {
|
if (stream?.language != null) {
|
||||||
streamChoiceService.updateAudio(item.data, stream.language!!)
|
streamChoiceService.saveSeriesTrackChoice(
|
||||||
|
item.data,
|
||||||
|
stream.type,
|
||||||
|
stream,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if (type == MediaStreamType.SUBTITLE) {
|
} else if (type == MediaStreamType.SUBTITLE) {
|
||||||
if (trackIndex == TrackIndex.DISABLED) {
|
if (trackIndex == TrackIndex.DISABLED) {
|
||||||
streamChoiceService.updateSubtitles(
|
streamChoiceService.saveDisabledSeriesTrackChoice(
|
||||||
item.data,
|
item.data,
|
||||||
subtitleLang = null,
|
MediaStreamType.SUBTITLE,
|
||||||
subtitlesDisabled = true,
|
)
|
||||||
|
} else if (trackIndex == TrackIndex.ONLY_FORCED) {
|
||||||
|
streamChoiceService.saveOnlyForcedSeriesTrackChoice(
|
||||||
|
item.data,
|
||||||
|
MediaStreamType.SUBTITLE,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
|
val stream =
|
||||||
|
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
|
||||||
if (stream?.language != null) {
|
if (stream?.language != null) {
|
||||||
streamChoiceService.updateSubtitles(
|
streamChoiceService.saveSeriesTrackChoice(
|
||||||
item.data,
|
item.data,
|
||||||
stream.language!!,
|
stream.type,
|
||||||
subtitlesDisabled = false,
|
stream,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,9 +278,10 @@ class ItemPlaybackRepository
|
||||||
*/
|
*/
|
||||||
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
||||||
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
||||||
chosenStreams?.plc?.let {
|
val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty()
|
||||||
Timber.d("Deleting %s", it)
|
if (stc.isNotEmpty()) {
|
||||||
playbackLanguageChoiceDao.delete(it)
|
Timber.d("Deleting %s", stc)
|
||||||
|
seriesTrackChoiceDao.delete(stc)
|
||||||
}
|
}
|
||||||
chosenStreams?.itemPlayback?.let {
|
chosenStreams?.itemPlayback?.let {
|
||||||
Timber.d("Deleting %s", it)
|
Timber.d("Deleting %s", it)
|
||||||
|
|
@ -256,11 +295,12 @@ class ItemPlaybackRepository
|
||||||
*/
|
*/
|
||||||
data class ChosenStreams(
|
data class ChosenStreams(
|
||||||
val itemPlayback: ItemPlayback?,
|
val itemPlayback: ItemPlayback?,
|
||||||
val plc: PlaybackLanguageChoice?,
|
val audioStc: List<SeriesTrackChoice>,
|
||||||
|
val subtitleStc: List<SeriesTrackChoice>,
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val source: MediaSourceInfo,
|
val source: MediaSourceInfo,
|
||||||
val videoStream: MediaStream?,
|
val videoStream: MediaStream?,
|
||||||
val audioStream: MediaStream?,
|
val audioStream: StreamChoiceResult,
|
||||||
val subtitleStream: MediaStream?,
|
val subtitleStream: StreamChoiceResult,
|
||||||
val subtitlesDisabled: Boolean,
|
val subtitlesDisabled: Boolean,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,27 @@ import androidx.room.Query
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Deprecated("Use SeriesTrackChoiceDao")
|
||||||
@Dao
|
@Dao
|
||||||
interface PlaybackLanguageChoiceDao {
|
interface PlaybackLanguageChoiceDao {
|
||||||
|
@Deprecated("Use SeriesTrackChoiceDao")
|
||||||
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
|
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
|
||||||
suspend fun get(
|
suspend fun get(
|
||||||
userId: Int,
|
userId: Int,
|
||||||
seriesId: UUID,
|
seriesId: UUID,
|
||||||
): PlaybackLanguageChoice?
|
): PlaybackLanguageChoice?
|
||||||
|
|
||||||
|
@Deprecated("Use SeriesTrackChoiceDao")
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun save(plc: PlaybackLanguageChoice): Long
|
suspend fun save(plc: PlaybackLanguageChoice): Long
|
||||||
|
|
||||||
|
@Deprecated("Use SeriesTrackChoiceDao")
|
||||||
@Delete
|
@Delete
|
||||||
fun delete(plc: PlaybackLanguageChoice)
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* DEPRECATED
|
||||||
|
*
|
||||||
* Stores the language choices for a series so they can be applied automatically to other episodes
|
* 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
|
* without the user needing to explicitly choose the tracks
|
||||||
|
*
|
||||||
|
* @see SeriesTrackChoice
|
||||||
*/
|
*/
|
||||||
|
@Deprecated("Use SeriesTrackChoice")
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
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.CurrentUser
|
||||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
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.RememberedTabDao
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
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.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.RememberedTab
|
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.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
|
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
|
||||||
|
|
@ -61,6 +64,8 @@ class AppUpgradeHandler
|
||||||
private val serverDao: JellyfinServerDao,
|
private val serverDao: JellyfinServerDao,
|
||||||
private val rememberedTabDao: RememberedTabDao,
|
private val rememberedTabDao: RememberedTabDao,
|
||||||
private val mostRecentServerProvider: MostRecentServerProvider,
|
private val mostRecentServerProvider: MostRecentServerProvider,
|
||||||
|
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||||
|
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||||
) {
|
) {
|
||||||
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
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
|
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.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.ActivationFlag
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
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.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.data.model.asRelative
|
||||||
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
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.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
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.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
|
@ -30,50 +42,69 @@ class StreamChoiceService
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||||
) {
|
) {
|
||||||
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
|
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
|
||||||
|
|
||||||
suspend fun updateAudio(
|
suspend fun saveSeriesTrackChoice(
|
||||||
dto: BaseItemDto,
|
dto: BaseItemDto,
|
||||||
audioLang: String,
|
type: MediaStreamType,
|
||||||
) = update(dto) {
|
stream: RelativeMediaStream?,
|
||||||
it.copy(
|
activation: ActivationFlag = ActivationFlag.ACTIVATED,
|
||||||
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,
|
|
||||||
) {
|
) {
|
||||||
val seriesId = dto.seriesId
|
val type =
|
||||||
if (seriesId != null) {
|
when (stream?.type ?: type) {
|
||||||
val userId = serverRepository.currentUser!!.rowId
|
MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
|
||||||
val currentPlc =
|
MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
|
||||||
playbackLanguageChoiceDao.get(userId, seriesId)
|
else -> return
|
||||||
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
|
}
|
||||||
val newPlc = update.invoke(currentPlc)
|
val userId = serverRepository.currentUser!!.rowId
|
||||||
Timber.v("Saving series PLC: %s", newPlc)
|
val newStc =
|
||||||
playbackLanguageChoiceDao.save(newPlc)
|
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) =
|
suspend fun saveDisabledSeriesTrackChoice(
|
||||||
dto.seriesId?.let {
|
dto: BaseItemDto,
|
||||||
playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
|
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
|
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
|
||||||
|
|
@ -104,21 +135,15 @@ class StreamChoiceService
|
||||||
*/
|
*/
|
||||||
suspend fun chooseAudioStream(
|
suspend fun chooseAudioStream(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
seriesId: UUID?,
|
item: BaseItemDto,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
plc: PlaybackLanguageChoice?,
|
stc: List<SeriesTrackChoice>?,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? {
|
): StreamChoiceResult? {
|
||||||
val plc =
|
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
|
||||||
plc ?: seriesId?.let {
|
|
||||||
playbackLanguageChoiceDao.get(
|
|
||||||
serverRepository.currentUser!!.rowId,
|
|
||||||
it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return source.mediaStreams?.letNotEmpty { streams ->
|
return source.mediaStreams?.letNotEmpty { streams ->
|
||||||
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
|
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(
|
fun chooseAudioStream(
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
stc: List<SeriesTrackChoice>,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? =
|
): StreamChoiceResult =
|
||||||
if (itemPlayback?.audioIndexEnabled == true) {
|
if (itemPlayback?.audioIndexEnabled == true) {
|
||||||
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
|
candidates.firstOrNull { it.index == itemPlayback.audioIndex } with
|
||||||
} else {
|
StreamChoiceReason.Item(
|
||||||
val seriesLang =
|
itemPlayback,
|
||||||
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
)
|
||||||
// If the user has chosen a different language for the series, prefer that
|
} else if (stc.isNotEmpty()) {
|
||||||
val audioLanguage =
|
val first = stc.first()
|
||||||
seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
|
val result = scoreStreams(candidates, first)
|
||||||
if (audioLanguage.isNotNullOrBlank()) {
|
if (result == null) {
|
||||||
val sorted =
|
null with StreamChoiceReason.from(first)
|
||||||
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
} else if (result.isEmpty() && stc.size > 1) {
|
||||||
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
|
// SeriesTrackChoice did not apply to any streams, but there are more options
|
||||||
?: sorted.firstOrNull { it.language == audioLanguage }
|
chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs)
|
||||||
?: sorted.firstOrNull { it.isDefault }
|
} else if (result.isEmpty()) {
|
||||||
?: sorted.firstOrNull()
|
// SeriesTrackChoice did not apply to any streams, so use regular selection logic
|
||||||
|
Timber.v("No audio STC applied")
|
||||||
|
chooseAudioStream(candidates, itemPlayback, emptyList(), prefs)
|
||||||
} else {
|
} else {
|
||||||
candidates.firstOrNull { it.isDefault }
|
// Otherwise, use the best scored stream
|
||||||
?: candidates.firstOrNull()
|
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
|
* Returns the subtitle stream that should play
|
||||||
*/
|
*/
|
||||||
suspend fun chooseSubtitleStream(
|
suspend fun chooseSubtitleStream(
|
||||||
|
item: BaseItemDto,
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
audioStream: MediaStream?,
|
audioStream: MediaStream?,
|
||||||
seriesId: UUID?,
|
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
plc: PlaybackLanguageChoice?,
|
stc: List<SeriesTrackChoice>?,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? {
|
): StreamChoiceResult? =
|
||||||
val plc =
|
source.mediaStreams?.letNotEmpty { streams ->
|
||||||
plc ?: seriesId?.let {
|
|
||||||
playbackLanguageChoiceDao.get(
|
|
||||||
serverRepository.currentUser!!.rowId,
|
|
||||||
it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return source.mediaStreams?.letNotEmpty { streams ->
|
|
||||||
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
|
||||||
chooseSubtitleStream(
|
chooseSubtitleStream(
|
||||||
audioStream?.language,
|
audioStream?.language,
|
||||||
candidates,
|
candidates,
|
||||||
itemPlayback,
|
itemPlayback,
|
||||||
plc,
|
stc,
|
||||||
prefs,
|
prefs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves ONLY_FORCED to an actual subtitle track index.
|
* Resolves ONLY_FORCED to an actual subtitle track index.
|
||||||
|
|
@ -189,7 +225,7 @@ class StreamChoiceService
|
||||||
suspend fun resolveSubtitleIndex(
|
suspend fun resolveSubtitleIndex(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
audioStreamIndex: Int?,
|
audioStreamIndex: Int?,
|
||||||
seriesId: UUID?,
|
item: BaseItemDto,
|
||||||
subtitleIndex: Int,
|
subtitleIndex: Int,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): Int? =
|
): Int? =
|
||||||
|
|
@ -203,17 +239,17 @@ class StreamChoiceService
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
ItemPlayback(
|
ItemPlayback(
|
||||||
userId = serverRepository.currentUser!!.rowId,
|
userId = serverRepository.currentUser!!.rowId,
|
||||||
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
|
itemId = item.id,
|
||||||
subtitleIndex = TrackIndex.ONLY_FORCED,
|
subtitleIndex = TrackIndex.ONLY_FORCED,
|
||||||
)
|
)
|
||||||
chooseSubtitleStream(
|
chooseSubtitleStream(
|
||||||
|
item = item,
|
||||||
source = source,
|
source = source,
|
||||||
audioStream = audioStream,
|
audioStream = audioStream,
|
||||||
seriesId = seriesId,
|
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
plc = null,
|
stc = null,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
)?.index
|
)?.stream?.index
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -223,47 +259,81 @@ class StreamChoiceService
|
||||||
audioStreamLang: String?,
|
audioStreamLang: String?,
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
stc: List<SeriesTrackChoice>,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? {
|
): StreamChoiceResult {
|
||||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) {
|
||||||
return null
|
if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) {
|
||||||
}
|
return null with StreamChoiceReason.Item(itemPlayback)
|
||||||
val seriesLang =
|
} else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
val subtitleLanguage =
|
||||||
val subtitleLanguage =
|
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||||
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
// Client-side manual override: User selected "Only Forced" in player menu
|
||||||
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
return findForcedTrack(
|
||||||
// Client-side manual override: User selected "Only Forced" in player menu
|
candidates,
|
||||||
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
|
subtitleLanguage,
|
||||||
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
audioStreamLang,
|
||||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
) 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 {
|
} else {
|
||||||
|
val subtitleLanguage =
|
||||||
|
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||||
val subtitleMode =
|
val subtitleMode =
|
||||||
when {
|
when (prefs.userPreferences?.subtitleMode) {
|
||||||
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
|
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
|
||||||
// User has chosen a series level subtitle language, so override their normal
|
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
|
||||||
// subtitle mode to display that language
|
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
|
||||||
SubtitlePlaybackMode.ALWAYS
|
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
|
||||||
}
|
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
|
||||||
|
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
|
||||||
playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> {
|
null -> SubtitlePlaybackMode.DEFAULT
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: SubtitlePlaybackMode.DEFAULT
|
} ?: SubtitlePlaybackMode.DEFAULT
|
||||||
val candidates =
|
val candidates =
|
||||||
candidates
|
candidates
|
||||||
|
|
@ -277,73 +347,104 @@ class StreamChoiceService
|
||||||
}.thenByDescending { it.isForced && it.language.isUnknown }
|
}.thenByDescending { it.isForced && it.language.isUnknown }
|
||||||
.thenByDescending { it.isForced },
|
.thenByDescending { it.isForced },
|
||||||
)
|
)
|
||||||
return when (subtitleMode) {
|
val stream =
|
||||||
SubtitlePlaybackMode.ALWAYS -> {
|
when (subtitleMode) {
|
||||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
SubtitlePlaybackMode.ALWAYS -> {
|
||||||
candidates.firstOrNull {
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
// Prefer non-forced first
|
candidates.firstOrNull {
|
||||||
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
|
// Prefer non-forced first
|
||||||
} ?: candidates.firstOrNull {
|
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
|
||||||
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 }
|
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, show forced subtitles in preferred lang
|
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
|
||||||
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
|
|
||||||
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
|
|
||||||
}
|
}
|
||||||
} 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return stream with StreamChoiceReason.UserPreferences
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if the track is forced (via metadata flag or title patterns). */
|
/**
|
||||||
private fun isForcedOrSigns(track: MediaStream): Boolean {
|
* Recursively calls [chooseSubtitleStream] based on [result] and [stc]
|
||||||
if (track.isForced) return true
|
*/
|
||||||
val title = track.title ?: track.displayTitle ?: return false
|
private fun handleSubtitleStreamChoiceResult(
|
||||||
return title.contains("forced", ignoreCase = true) ||
|
result: List<ScoredMediaStream>,
|
||||||
title.contains("signs", ignoreCase = true) ||
|
audioStreamLang: String?,
|
||||||
title.contains("songs", ignoreCase = true)
|
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. */
|
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
|
||||||
|
|
@ -367,19 +468,92 @@ class StreamChoiceService
|
||||||
// 3. Unknown language forced track
|
// 3. Unknown language forced track
|
||||||
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
|
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
|
private val String?.isUnknown: Boolean
|
||||||
get() =
|
get() =
|
||||||
this == null ||
|
this.isNullOrBlank() ||
|
||||||
this.equals("unknown", true) ||
|
this.equals("unknown", true) ||
|
||||||
this.equals("und", true) ||
|
this.equals("und", true) ||
|
||||||
this.equals("undetermined", true) ||
|
this.equals("undetermined", true) ||
|
||||||
this.equals("mul", true) ||
|
this.equals("mul", true) ||
|
||||||
this.equals("zxx", 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?.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
|
* Based on the user's preferences, get their preferred language for audio or subtitles
|
||||||
*
|
*
|
||||||
|
|
@ -412,3 +586,153 @@ fun getPreferredLanguage(
|
||||||
else -> pref
|
else -> pref
|
||||||
}?.takeIf { it.isNotNullOrBlank() }
|
}?.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.PlaybackLanguageChoiceDao
|
||||||
import com.github.damontecres.wholphin.data.RememberedTabDao
|
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
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.ServerPreferencesDao
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||||
|
|
@ -79,6 +80,10 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
fun userPreferencesDataStore(
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,12 @@ fun ContextMenu(
|
||||||
favorite = item.favorite,
|
favorite = item.favorite,
|
||||||
seriesId = item.data.seriesId,
|
seriesId = item.data.seriesId,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
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,
|
contextMenu = contextMenu,
|
||||||
actions = actions,
|
actions = actions,
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
|
|
@ -235,11 +240,11 @@ fun ContextMenu(
|
||||||
resources = resources,
|
resources = resources,
|
||||||
streams = source.mediaStreams.orEmpty(),
|
streams = source.mediaStreams.orEmpty(),
|
||||||
type = type,
|
type = type,
|
||||||
currentIndex =
|
streamChoice =
|
||||||
if (type == MediaStreamType.AUDIO) {
|
if (type == MediaStreamType.AUDIO) {
|
||||||
chosenStreams?.audioStream?.index
|
chosenStreams?.audioStream
|
||||||
} else {
|
} else {
|
||||||
chosenStreams?.subtitleStream?.index
|
chosenStreams?.subtitleStream
|
||||||
},
|
},
|
||||||
onClick = { trackIndex ->
|
onClick = { trackIndex ->
|
||||||
actions.onChooseTracks.invoke(
|
actions.onChooseTracks.invoke(
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
|
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.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -651,12 +652,13 @@ fun resourceFor(type: MediaStreamType): Int =
|
||||||
fun chooseStream(
|
fun chooseStream(
|
||||||
resources: Resources,
|
resources: Resources,
|
||||||
streams: List<MediaStream>,
|
streams: List<MediaStream>,
|
||||||
currentIndex: Int?,
|
streamChoice: StreamChoiceResult?,
|
||||||
type: MediaStreamType,
|
type: MediaStreamType,
|
||||||
preferredSubtitleLanguage: String?,
|
preferredSubtitleLanguage: String?,
|
||||||
onClick: (Int) -> Unit,
|
onClick: (Int) -> Unit,
|
||||||
): DialogParams {
|
): DialogParams {
|
||||||
val filteredStreams = streams.filter { it.type == type }
|
val filteredStreams = streams.filter { it.type == type }
|
||||||
|
val currentIndex = streamChoice?.stream?.index
|
||||||
return DialogParams(
|
return DialogParams(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
|
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
|
||||||
|
|
@ -674,6 +676,16 @@ fun chooseStream(
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
},
|
},
|
||||||
|
trailingContent =
|
||||||
|
if (currentIndex == null && streamChoice != null) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = streamChoice.reason.description.getString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -719,6 +731,16 @@ fun chooseStream(
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
|
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) },
|
onClick = { onClick.invoke(stream.index) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ fun VideoStreamDetails(
|
||||||
) = VideoStreamDetails(
|
) = VideoStreamDetails(
|
||||||
chosenStreams?.source,
|
chosenStreams?.source,
|
||||||
chosenStreams?.videoStream,
|
chosenStreams?.videoStream,
|
||||||
chosenStreams?.audioStream,
|
chosenStreams?.audioStream?.stream,
|
||||||
chosenStreams?.subtitleStream,
|
chosenStreams?.subtitleStream?.stream,
|
||||||
numberOfVersions,
|
numberOfVersions,
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
|
@ -56,6 +57,7 @@ import kotlinx.coroutines.withContext
|
||||||
import org.acra.util.versionCodeLong
|
import org.acra.util.versionCodeLong
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
@ -68,6 +70,7 @@ class DebugViewModel
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
val itemPlaybackDao: ItemPlaybackDao,
|
||||||
|
val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||||
val clientInfo: ClientInfo,
|
val clientInfo: ClientInfo,
|
||||||
val deviceInfo: DeviceInfo,
|
val deviceInfo: DeviceInfo,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
@ -169,6 +172,13 @@ class DebugViewModel
|
||||||
itemPlaybacks.value = results
|
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 {
|
viewModelScope.launchIO {
|
||||||
val logcat = getLogCatLines()
|
val logcat = getLogCatLines()
|
||||||
this@DebugViewModel.logcat.value = logcat
|
this@DebugViewModel.logcat.value = logcat
|
||||||
|
|
|
||||||
|
|
@ -145,11 +145,10 @@ class EpisodeViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
}
|
}
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +162,6 @@ class EpisodeViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -171,10 +169,7 @@ class EpisodeViewModel
|
||||||
trackIndex = trackIndex,
|
trackIndex = trackIndex,
|
||||||
type = type,
|
type = type,
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
result?.let {
|
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
|
||||||
}
|
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,11 +223,10 @@ class MovieViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
}
|
}
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +240,6 @@ class MovieViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -249,10 +247,7 @@ class MovieViewModel
|
||||||
trackIndex = trackIndex,
|
trackIndex = trackIndex,
|
||||||
type = type,
|
type = type,
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
result?.let {
|
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
|
||||||
}
|
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -561,11 +561,10 @@ class SeriesViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
}
|
}
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
|
|
@ -579,7 +578,6 @@ class SeriesViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -587,10 +585,7 @@ class SeriesViewModel
|
||||||
trackIndex = trackIndex,
|
trackIndex = trackIndex,
|
||||||
type = type,
|
type = type,
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
result?.let {
|
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
|
||||||
}
|
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
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.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||||
|
|
@ -158,7 +159,11 @@ class PlayExternalViewModel
|
||||||
is PlaylistItem.Media -> playlistItem.item
|
is PlaylistItem.Media -> playlistItem.item
|
||||||
}
|
}
|
||||||
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
|
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
val subtitleStc =
|
||||||
|
streamChoiceService.getSeriesTrackChoices(
|
||||||
|
item.data,
|
||||||
|
SeriesTrackChoiceType.SUBTITLE,
|
||||||
|
)
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
Timber.w("Media source is null")
|
Timber.w("Media source is null")
|
||||||
return@launchDefault
|
return@launchDefault
|
||||||
|
|
@ -168,13 +173,14 @@ class PlayExternalViewModel
|
||||||
val subtitleIndex =
|
val subtitleIndex =
|
||||||
streamChoiceService
|
streamChoiceService
|
||||||
.chooseSubtitleStream(
|
.chooseSubtitleStream(
|
||||||
|
item = item.data,
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
audioStream = null,
|
audioStream = null,
|
||||||
seriesId = item.data.seriesId,
|
|
||||||
itemPlayback = playbackConfig,
|
itemPlayback = playbackConfig,
|
||||||
plc = plc,
|
stc = subtitleStc,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
)?.index
|
)?.stream
|
||||||
|
?.index
|
||||||
val externalSubtitles =
|
val externalSubtitles =
|
||||||
mediaSource.mediaStreams
|
mediaSource.mediaStreams
|
||||||
?.filter { it.isExternal }
|
?.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.Chapter
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
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.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
|
@ -490,7 +491,16 @@ class PlaybackViewModel
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
val audioStc =
|
||||||
|
streamChoiceService.getSeriesTrackChoices(
|
||||||
|
base,
|
||||||
|
SeriesTrackChoiceType.AUDIO,
|
||||||
|
)
|
||||||
|
val subtitleStc =
|
||||||
|
streamChoiceService.getSeriesTrackChoices(
|
||||||
|
base,
|
||||||
|
SeriesTrackChoiceType.AUDIO,
|
||||||
|
)
|
||||||
|
|
||||||
if (mediaSource == null && !isLiveTv) {
|
if (mediaSource == null && !isLiveTv) {
|
||||||
showToast(
|
showToast(
|
||||||
|
|
@ -519,30 +529,31 @@ class PlaybackViewModel
|
||||||
|
|
||||||
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
|
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
|
||||||
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
|
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
|
||||||
val audioStream =
|
val audioResult =
|
||||||
mediaSource?.let {
|
mediaSource?.let {
|
||||||
streamChoiceService
|
streamChoiceService
|
||||||
.chooseAudioStream(
|
.chooseAudioStream(
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
seriesId = base.seriesId,
|
item = base,
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
plc = plc,
|
stc = audioStc,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val audioIndex = audioStream?.index
|
val audioIndex = audioResult?.stream?.index
|
||||||
|
|
||||||
val subtitleIndex =
|
val subtitleIndex =
|
||||||
mediaSource?.let {
|
mediaSource?.let {
|
||||||
streamChoiceService
|
streamChoiceService
|
||||||
.chooseSubtitleStream(
|
.chooseSubtitleStream(
|
||||||
|
item = base,
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
audioStream = audioStream,
|
audioStream = audioResult?.stream,
|
||||||
seriesId = base.seriesId,
|
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
plc = plc,
|
stc = subtitleStc,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)?.index
|
)?.stream
|
||||||
|
?.index
|
||||||
}
|
}
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
|
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
|
||||||
|
|
@ -956,7 +967,7 @@ class PlaybackViewModel
|
||||||
streamChoiceService.resolveSubtitleIndex(
|
streamChoiceService.resolveSubtitleIndex(
|
||||||
source = currentPlayback.mediaSourceInfo,
|
source = currentPlayback.mediaSourceInfo,
|
||||||
audioStreamIndex = index,
|
audioStreamIndex = index,
|
||||||
seriesId = currentItem.item.data.seriesId,
|
item = currentItem.item.data,
|
||||||
subtitleIndex = currentPlayback.subtitleIndex,
|
subtitleIndex = currentPlayback.subtitleIndex,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)
|
)
|
||||||
|
|
@ -992,7 +1003,7 @@ class PlaybackViewModel
|
||||||
streamChoiceService.resolveSubtitleIndex(
|
streamChoiceService.resolveSubtitleIndex(
|
||||||
source = currentPlayback.mediaSourceInfo,
|
source = currentPlayback.mediaSourceInfo,
|
||||||
audioStreamIndex = currentPlayback.audioIndex,
|
audioStreamIndex = currentPlayback.audioIndex,
|
||||||
seriesId = currentItem.item.data.seriesId,
|
item = currentItem.item.data,
|
||||||
subtitleIndex = index,
|
subtitleIndex = index,
|
||||||
prefs = preferences,
|
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.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
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.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.PhotoItemFields
|
import com.github.damontecres.wholphin.ui.PhotoItemFields
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -229,12 +231,21 @@ class SlideshowViewModel
|
||||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
||||||
ChosenStreams(
|
ChosenStreams(
|
||||||
itemPlayback = null,
|
itemPlayback = null,
|
||||||
plc = null,
|
audioStc = emptyList(),
|
||||||
|
subtitleStc = emptyList(),
|
||||||
itemId = image.id,
|
itemId = image.id,
|
||||||
source = source,
|
source = source,
|
||||||
videoStream = video,
|
videoStream = video,
|
||||||
audioStream = audio,
|
audioStream =
|
||||||
subtitleStream = null,
|
StreamChoiceResult(
|
||||||
|
audio,
|
||||||
|
StreamChoiceReason.Unknown,
|
||||||
|
),
|
||||||
|
subtitleStream =
|
||||||
|
StreamChoiceResult(
|
||||||
|
null,
|
||||||
|
StreamChoiceReason.Unknown,
|
||||||
|
),
|
||||||
subtitlesDisabled = false,
|
subtitlesDisabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -964,4 +964,7 @@
|
||||||
<item quantity="one">%1$d Episode</item>
|
<item quantity="one">%1$d Episode</item>
|
||||||
<item quantity="other">%1$d Episodes</item>
|
<item quantity="other">%1$d Episodes</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="chosen_by">Chosen by %1$s</string>
|
||||||
|
<string name="user">User</string>
|
||||||
|
<string name="series">Series</string>
|
||||||
</resources>
|
</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
|
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.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.ActivationFlag
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
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.ServerUserConfig
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -523,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced(
|
||||||
),
|
),
|
||||||
streamAudioLang = "eng",
|
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 streamAudioLang: String? = "eng",
|
||||||
val subtitles: List<MediaStream>,
|
val subtitles: List<MediaStream>,
|
||||||
val itemPlayback: ItemPlayback? = null,
|
val itemPlayback: ItemPlayback? = null,
|
||||||
val plc: PlaybackLanguageChoice? = null,
|
val plc: List<SeriesTrackChoice> = emptyList(),
|
||||||
val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE,
|
val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE,
|
||||||
val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE,
|
val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE,
|
||||||
val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.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 MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
|
||||||
|
|
||||||
private fun serverRepo(
|
fun mockServerRepo(
|
||||||
audioLang: String?,
|
audioLang: String?,
|
||||||
subtitleMode: SubtitlePlaybackMode?,
|
subtitleMode: SubtitlePlaybackMode?,
|
||||||
subtitleLang: String?,
|
subtitleLang: String?,
|
||||||
|
|
@ -918,15 +968,15 @@ private fun serverRepo(
|
||||||
private fun runTest(input: TestInput) {
|
private fun runTest(input: TestInput) {
|
||||||
val service =
|
val service =
|
||||||
StreamChoiceService(
|
StreamChoiceService(
|
||||||
serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
|
mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
|
||||||
mockk<PlaybackLanguageChoiceDao>(),
|
mockk<SeriesTrackChoiceDao>(),
|
||||||
)
|
)
|
||||||
val result =
|
val result =
|
||||||
service.chooseSubtitleStream(
|
service.chooseSubtitleStream(
|
||||||
audioStreamLang = input.streamAudioLang,
|
audioStreamLang = input.streamAudioLang,
|
||||||
candidates = input.subtitles,
|
candidates = input.subtitles,
|
||||||
itemPlayback = input.itemPlayback,
|
itemPlayback = input.itemPlayback,
|
||||||
playbackLanguageChoice = input.plc,
|
stc = input.plc,
|
||||||
prefs =
|
prefs =
|
||||||
UserPreferences(
|
UserPreferences(
|
||||||
AppPreferences.getDefaultInstance(),
|
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(
|
fun subtitle(
|
||||||
|
|
@ -945,6 +995,8 @@ fun subtitle(
|
||||||
lang: String?,
|
lang: String?,
|
||||||
default: Boolean = false,
|
default: Boolean = false,
|
||||||
forced: Boolean = false,
|
forced: Boolean = false,
|
||||||
|
sdh: Boolean = false,
|
||||||
|
external: Boolean = false,
|
||||||
title: String? = null,
|
title: String? = null,
|
||||||
): MediaStream =
|
): MediaStream =
|
||||||
MediaStream(
|
MediaStream(
|
||||||
|
|
@ -952,6 +1004,26 @@ fun subtitle(
|
||||||
language = lang,
|
language = lang,
|
||||||
isDefault = default,
|
isDefault = default,
|
||||||
isForced = forced,
|
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,
|
isHearingImpaired = false,
|
||||||
isInterlaced = false,
|
isInterlaced = false,
|
||||||
index = index,
|
index = index,
|
||||||
|
|
@ -978,11 +1050,63 @@ private fun plc(
|
||||||
audioLang: String? = null,
|
audioLang: String? = null,
|
||||||
subtitleLang: String? = null,
|
subtitleLang: String? = null,
|
||||||
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
|
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
|
||||||
): PlaybackLanguageChoice =
|
subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null,
|
||||||
PlaybackLanguageChoice(
|
): List<SeriesTrackChoice> =
|
||||||
userId = 1,
|
buildList {
|
||||||
seriesId = UUID.randomUUID(),
|
audioLang
|
||||||
audioLanguage = audioLang,
|
?.let {
|
||||||
subtitleLanguage = subtitleLang,
|
SeriesTrackChoice(
|
||||||
subtitlesDisabled = subtitlesDisabled,
|
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.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.NameGuidPair
|
import org.jellyfin.sdk.model.api.NameGuidPair
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import kotlin.contracts.ExperimentalContracts
|
import kotlin.contracts.ExperimentalContracts
|
||||||
import kotlin.contracts.contract
|
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} ")
|
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.PlaybackLanguageChoiceDao
|
||||||
import com.github.damontecres.wholphin.data.RememberedTabDao
|
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
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.ServerPreferencesDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -266,6 +267,10 @@ object TestDatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
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.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
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.data.model.ServerUserConfig
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
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.PlaylistCreator
|
||||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
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.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.test.TestTracks
|
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.api.operations.VideosApi
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import org.jellyfin.sdk.model.UUID
|
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.MediaSegmentDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult
|
import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
|
|
@ -201,7 +205,12 @@ class PlaybackViewModelTests {
|
||||||
|
|
||||||
coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null
|
coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null
|
||||||
coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
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
|
coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns
|
||||||
PlayerCreation(mockPlayer)
|
PlayerCreation(mockPlayer)
|
||||||
every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true)
|
every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true)
|
||||||
|
|
@ -214,18 +223,26 @@ class PlaybackViewModelTests {
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
} returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }
|
} returns
|
||||||
|
StreamChoiceResult(
|
||||||
|
mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO },
|
||||||
|
StreamChoiceReason.Unknown,
|
||||||
|
)
|
||||||
|
|
||||||
coEvery {
|
coEvery {
|
||||||
mockStreamChoiceService.chooseSubtitleStream(
|
mockStreamChoiceService.chooseSubtitleStream(
|
||||||
|
item = any(),
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
any(),
|
audioStream = any(),
|
||||||
any(),
|
itemPlayback = any(),
|
||||||
any(),
|
stc = any(),
|
||||||
any(),
|
prefs = any(),
|
||||||
any(),
|
)
|
||||||
|
} returns
|
||||||
|
StreamChoiceResult(
|
||||||
|
mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE },
|
||||||
|
StreamChoiceReason.Unknown,
|
||||||
)
|
)
|
||||||
} returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue