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