Add support for server plugin (#1384)
## Description PR to add support for a Wholphin server plugin: https://github.com/damontecres/jellyfin-plugin-wholphin Note: The plugin is not required to use Wholphin. It adds features the server admin can use to set default settings like the home page or eventually Seerr connection info. ### Changes Wholphin will use the home page settings in order of: 1. Saved on the device locally 2. Saved on the server in the user's profile settings (display preferences) 3. Provided by `jellyfin-plugin-wholphin` 4. Default fallback: combined continue watching & recently added in libraries Under customize home page, you can now see the "source" of the home settings. This also allows to pick a different source Finally, instead of the home customization being saved in the background only locally, if there are any changes, a dialog is shown with options to save locally, server-side user profile, or discard. So overall, it's more clear where the home page settings are from and where the user wants to save them. ### Install [See instructions here](https://github.com/damontecres/Wholphin/releases/tag/develop-server-plugin) ### Related issues Related to #598 ### Testing WIP ## Screenshots N/A ## AI or LLM usage None --------- Co-authored-by: Mario Reisinger <marioreisinger95@gmail.com>
This commit is contained in:
parent
e273195e93
commit
551501fa75
20 changed files with 2331 additions and 858 deletions
|
|
@ -0,0 +1,841 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 41,
|
||||
"identityHash": "1f2fe7912f9e3ab83b77029e07488e84",
|
||||
"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', `c_homeSettingsSource` TEXT NOT NULL DEFAULT 'UNSET', 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'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "config.homeSettingsSource",
|
||||
"columnName": "c_homeSettingsSource",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'UNSET'"
|
||||
}
|
||||
],
|
||||
"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, '1f2fe7912f9e3ab83b77029e07488e84')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ import java.util.UUID
|
|||
RememberedTab::class,
|
||||
SeriesTrackChoice::class,
|
||||
],
|
||||
version = 40,
|
||||
version = 41,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -71,6 +71,7 @@ import java.util.UUID
|
|||
AutoMigration(33, 34),
|
||||
AutoMigration(34, 35),
|
||||
AutoMigration(35, 40),
|
||||
AutoMigration(40, 41),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ class ServerRepository
|
|||
}
|
||||
}
|
||||
|
||||
val serverPluginInstalled = MutableStateFlow<Boolean>(false)
|
||||
|
||||
/**
|
||||
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
@file:UseSerializers(
|
||||
UUIDSerializer::class,
|
||||
DateTimeSerializer::class,
|
||||
)
|
||||
|
||||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
|
|
@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio
|
|||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.serializer.DateTimeSerializer
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -174,19 +181,45 @@ sealed interface HomeRowConfig {
|
|||
}
|
||||
|
||||
/**
|
||||
* An arbitrary [GetItemsRequest] allowing to query for anything
|
||||
* An arbitrary [HomeGetItemsFilter] allowing to query for most anything
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("GetItems")
|
||||
data class GetItems(
|
||||
val name: String,
|
||||
val getItems: GetItemsRequest,
|
||||
val filter: HomeGetItemsFilter,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult<BaseItemDto>.
|
||||
* Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing
|
||||
* specific knowledge of them.
|
||||
*
|
||||
* Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin
|
||||
* config is persisted as XML, which doesn't support IDictionary.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("CustomEndpoint")
|
||||
data class CustomEndpoint(
|
||||
val endpoint: String,
|
||||
val title: String,
|
||||
val headers: List<KeyValueEntry>? = null,
|
||||
val query: List<KeyValueEntry>? = null,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KeyValueEntry(
|
||||
val key: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Root class for home page settings
|
||||
*
|
||||
|
|
@ -241,3 +274,54 @@ data class HomeRowViewOptions(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of [GetItemsFilter]
|
||||
*/
|
||||
@Serializable
|
||||
data class HomeGetItemsFilter(
|
||||
val sortBy: List<ItemSortBy>? = null,
|
||||
val sortOrder: List<SortOrder>? = null,
|
||||
val ids: List<UUID>? = null,
|
||||
val minCommunityRating: Double? = null,
|
||||
val minCriticRating: Double? = null,
|
||||
val recursive: Boolean? = null,
|
||||
val searchTerm: String? = null,
|
||||
val parentId: UUID? = null,
|
||||
val includeItemTypes: List<BaseItemKind>? = null,
|
||||
val isFavorite: Boolean? = null,
|
||||
val isPlayed: Boolean? = null,
|
||||
val officialRatings: Collection<String>? = null,
|
||||
val tags: List<String>? = null,
|
||||
val years: List<Int>? = null,
|
||||
val personIds: List<UUID>? = null,
|
||||
val minOfficialRating: String? = null,
|
||||
val hasOfficialRating: Boolean? = null,
|
||||
val studioIds: List<UUID>? = null,
|
||||
val genreIds: List<UUID>? = null,
|
||||
val minPremiereDate: LocalDateTime? = null,
|
||||
val maxPremiereDate: LocalDateTime? = null,
|
||||
) {
|
||||
fun asGetItemsRequest() =
|
||||
GetItemsRequest(
|
||||
sortBy = sortBy,
|
||||
sortOrder = sortOrder,
|
||||
ids = ids,
|
||||
minCommunityRating = minCommunityRating,
|
||||
minCriticRating = minCriticRating,
|
||||
recursive = recursive,
|
||||
searchTerm = searchTerm,
|
||||
parentId = parentId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
isFavorite = isFavorite,
|
||||
isPlayed = isPlayed,
|
||||
officialRatings = officialRatings,
|
||||
tags = tags,
|
||||
years = years,
|
||||
personIds = personIds,
|
||||
minOfficialRating = minOfficialRating,
|
||||
hasOfficialRating = hasOfficialRating,
|
||||
studioIds = studioIds,
|
||||
genreIds = genreIds,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.room.Relation
|
|||
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
|
||||
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||
import com.github.damontecres.wholphin.services.HomePageSettingsSource
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
|
|
@ -68,6 +69,7 @@ data class JellyfinUser(
|
|||
val lastUsed: ZonedDateTime? = null,
|
||||
val uiLanguage: String? = null,
|
||||
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
|
||||
@Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(),
|
||||
) {
|
||||
val hasPin: Boolean get() = pin.isNotNullOrBlank()
|
||||
|
||||
|
|
@ -77,9 +79,17 @@ data class JellyfinUser(
|
|||
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
|
||||
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
|
||||
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
|
||||
"appPreferences=$appPreferences"
|
||||
"appPreferences=$appPreferences, config=$config"
|
||||
|
||||
fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config))
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class JellyfinUserConfig(
|
||||
@ColumnInfo(defaultValue = "UNSET")
|
||||
val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,764 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.HomeItemFields
|
||||
import com.github.damontecres.wholphin.ui.ProgramItemFields
|
||||
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
|
||||
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.playback.getTypeFor
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.StringStringProvider
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetArtistsHandler
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
|
||||
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.DateTime
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Singleton
|
||||
class HomeDataService
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val suggestionService: SuggestionService,
|
||||
) {
|
||||
/**
|
||||
* Fetch the data from the server for a given [HomeRowConfig]
|
||||
*/
|
||||
suspend fun fetchDataForRow(
|
||||
row: HomeRowConfig,
|
||||
scope: CoroutineScope,
|
||||
prefs: HomePagePreferences,
|
||||
userDto: ServerUserConfig,
|
||||
libraries: List<Library>,
|
||||
limit: Int = prefs.maxItemsPerRow,
|
||||
isRefresh: Boolean,
|
||||
usePaging: Boolean = false,
|
||||
): HomeRowLoadingState =
|
||||
when (row) {
|
||||
is HomeRowConfig.ContinueWatching -> {
|
||||
val resume =
|
||||
latestNextUpService.getResume(
|
||||
userDto.id,
|
||||
limit,
|
||||
true,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
items = resume,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = resume.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.NextUp -> {
|
||||
val nextUp =
|
||||
latestNextUpService.getNextUp(
|
||||
userDto.id,
|
||||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.next_up),
|
||||
items = nextUp,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = nextUp.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.ContinueWatchingCombined -> {
|
||||
val resume =
|
||||
latestNextUpService.getResume(
|
||||
userDto.id,
|
||||
limit,
|
||||
true,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
val nextUp =
|
||||
latestNextUpService.getNextUp(
|
||||
userDto.id,
|
||||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
val combined = latestNextUpService.buildCombined(resume, nextUp)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
items = combined.take(limit),
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = combined.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.Genres -> {
|
||||
val request =
|
||||
GetGenresRequest(
|
||||
parentId = row.parentId,
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
)
|
||||
val items =
|
||||
GetGenresRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
val genreIds = items.map { it.id }
|
||||
val genreImages =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
scope = scope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genreIds,
|
||||
parentId = row.parentId,
|
||||
includeItemTypes = null,
|
||||
cardWidthPx = null,
|
||||
useCache = isRefresh,
|
||||
)
|
||||
val library =
|
||||
libraries
|
||||
.firstOrNull { it.itemId == row.parentId }
|
||||
|
||||
val title =
|
||||
library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
|
||||
?: ResStringProvider(R.string.genres)
|
||||
val genres =
|
||||
items.map {
|
||||
BaseItem(
|
||||
it,
|
||||
false,
|
||||
genreImages[it.id],
|
||||
createGenreDestination(
|
||||
genreId = it.id,
|
||||
genreName = it.name ?: "",
|
||||
parentId = row.parentId,
|
||||
parentName = library?.name,
|
||||
includeItemTypes = library?.includeItemTypes,
|
||||
collectionType =
|
||||
library?.collectionType
|
||||
?: CollectionType.UNKNOWN,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Success(
|
||||
title,
|
||||
genres,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = genres.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.Studios -> {
|
||||
val request =
|
||||
GetStudiosRequest(
|
||||
parentId = row.parentId,
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||
)
|
||||
val items =
|
||||
GetStudiosRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
val library =
|
||||
libraries
|
||||
.firstOrNull { it.itemId == row.parentId }
|
||||
val title =
|
||||
library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
|
||||
?: ResStringProvider(R.string.studios)
|
||||
val studios =
|
||||
items.map {
|
||||
val imageUrl =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = it.id,
|
||||
imageType = ImageType.THUMB,
|
||||
tag = it.imageTags?.get(ImageType.THUMB),
|
||||
)
|
||||
BaseItem(
|
||||
it,
|
||||
false,
|
||||
imageUrl,
|
||||
createStudioDestination(
|
||||
studioId = it.id,
|
||||
name = it.name ?: "",
|
||||
parentId = row.parentId,
|
||||
parentName = library?.name,
|
||||
includeItemTypes = library?.includeItemTypes,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Success(
|
||||
title,
|
||||
studios,
|
||||
viewOptions = row.viewOptions,
|
||||
showViewMore = studios.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.RecentlyAdded -> {
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val title = getRecentlyAddedTitle(library?.name)
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = library.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = row.parentId,
|
||||
groupItems = true,
|
||||
limit = limit,
|
||||
isPlayed = null, // Server will handle user's preference
|
||||
)
|
||||
val latest =
|
||||
api.userLibraryApi
|
||||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
is HomeRowConfig.RecentlyReleased -> {
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val title =
|
||||
library?.name?.let {
|
||||
ResArgStringProvider(R.string.recently_released_in, it)
|
||||
} ?: ResStringProvider(R.string.recently_released)
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = row.parentId,
|
||||
limit = limit,
|
||||
sortBy =
|
||||
listOf(
|
||||
ItemSortBy.PREMIERE_DATE,
|
||||
ItemSortBy.SERIES_SORT_NAME,
|
||||
ItemSortBy.AIRED_EPISODE_ORDER,
|
||||
),
|
||||
sortOrder =
|
||||
listOf(
|
||||
SortOrder.DESCENDING,
|
||||
SortOrder.ASCENDING,
|
||||
SortOrder.DESCENDING,
|
||||
),
|
||||
fields = library.itemFields,
|
||||
recursive = true,
|
||||
maxPremiereDate = LocalDateTime.now(),
|
||||
isUnaired = false,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.ByParent -> {
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = userDto.id,
|
||||
parentId = row.parentId,
|
||||
recursive = row.recursive,
|
||||
sortBy =
|
||||
row.sort?.let {
|
||||
buildList {
|
||||
if (it.sort == ItemSortBy.RANDOM) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
add(ItemSortBy.RANDOM)
|
||||
} else {
|
||||
add(it.sort)
|
||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
sortOrder =
|
||||
row.sort?.let {
|
||||
buildList {
|
||||
if (it.sort == ItemSortBy.RANDOM) {
|
||||
add(SortOrder.ASCENDING)
|
||||
add(it.direction)
|
||||
} else {
|
||||
add(it.direction)
|
||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
limit = limit,
|
||||
fields = library.itemFields,
|
||||
)
|
||||
|
||||
// Not using getItemName because we want to throw the 404
|
||||
val title =
|
||||
api.userLibraryApi
|
||||
.getItem(
|
||||
userId = serverRepository.currentUser?.id,
|
||||
itemId = row.parentId,
|
||||
).content.name
|
||||
?.let { StringStringProvider(it) }
|
||||
?: ResStringProvider(R.string.collection)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.GetItems -> {
|
||||
val request =
|
||||
row.filter.asGetItemsRequest().copy(
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
fields = HomeItemFieldsBoxSets,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
StringStringProvider(row.name),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.Favorite -> {
|
||||
val title =
|
||||
ResProviderStringProvider(
|
||||
R.string.favorite_items_title,
|
||||
ResStringProvider(formatTypeName(row.kind)),
|
||||
)
|
||||
val resultList =
|
||||
when (row.kind) {
|
||||
BaseItemKind.PERSON -> {
|
||||
val request =
|
||||
GetPersonsRequest(
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
fields = HomeItemFields,
|
||||
isFavorite = true,
|
||||
enableImages = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
)
|
||||
|
||||
GetPersonsHandler.execute(api, request).toBaseItems()
|
||||
}
|
||||
|
||||
BaseItemKind.MUSIC_ARTIST -> {
|
||||
val request =
|
||||
GetArtistsRequest(
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
fields = HomeItemFields,
|
||||
isFavorite = true,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetArtistsHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetArtistsHandler
|
||||
.execute(api, request)
|
||||
.toBaseItems(row.viewOptions.useSeries)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val fields =
|
||||
if (row.kind == BaseItemKind.BOX_SET) {
|
||||
HomeItemFieldsBoxSets
|
||||
} else {
|
||||
HomeItemFields
|
||||
}
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = userDto.id,
|
||||
recursive = true,
|
||||
limit = limit,
|
||||
fields = fields,
|
||||
includeItemTypes = listOf(row.kind),
|
||||
isFavorite = true,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.toBaseItems(row.viewOptions.useSeries)
|
||||
}
|
||||
}
|
||||
}
|
||||
resultList.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.Recordings -> {
|
||||
val request =
|
||||
GetRecordingsRequest(
|
||||
userId = userDto.id,
|
||||
isInProgress = true,
|
||||
fields = HomeItemFields,
|
||||
limit = limit,
|
||||
enableImages = true,
|
||||
enableUserData = true,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetRecordingsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
api.liveTvApi
|
||||
.getRecordings(request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
ResStringProvider(R.string.active_recordings),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.TvPrograms -> {
|
||||
val request =
|
||||
GetProgramsDto(
|
||||
userId = userDto.id,
|
||||
fields = ProgramItemFields,
|
||||
limit = limit,
|
||||
enableUserData = true,
|
||||
enableImages = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
||||
imageTypeLimit = 1,
|
||||
isAiring = true,
|
||||
minEndDate = DateTime.now().plusMinutes(1),
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetProgramsDtoHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
api.liveTvApi
|
||||
.getPrograms(request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
ResStringProvider(R.string.watch_live),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.TvChannels -> {
|
||||
val request =
|
||||
GetLiveTvChannelsRequest(
|
||||
userId = userDto.id,
|
||||
fields = HomeItemFields,
|
||||
limit = limit,
|
||||
enableImages = true,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetLiveTvChannelsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
api.liveTvApi
|
||||
.getLiveTvChannels(request)
|
||||
.toBaseItems(api, row.viewOptions.useSeries)
|
||||
}.let {
|
||||
Success(
|
||||
ResStringProvider(R.string.channels),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.Suggestions -> {
|
||||
val library =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = row.parentId)
|
||||
.content
|
||||
val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
|
||||
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
|
||||
if (itemKind != null) {
|
||||
val suggestions =
|
||||
suggestionService
|
||||
.getSuggestionsFlow(row.parentId, itemKind)
|
||||
.firstOrNull()
|
||||
when (suggestions) {
|
||||
SuggestionsResource.Empty -> {
|
||||
Success(
|
||||
title,
|
||||
listOf(),
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Success -> {
|
||||
Success(
|
||||
title,
|
||||
suggestions.items,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = suggestions.items.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
SuggestionsResource.Loading,
|
||||
null,
|
||||
-> {
|
||||
HomeRowLoadingState.Loading(title)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HomeRowLoadingState.Error(
|
||||
title = title,
|
||||
message = "Unsupported type ${library.collectionType}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.CustomEndpoint -> {
|
||||
val title = StringStringProvider(row.title)
|
||||
try {
|
||||
val items =
|
||||
fetchCustomEndpointItems(row)
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
Success(title, items, row.viewOptions, rowType = row)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Custom endpoint %s failed", row.endpoint)
|
||||
HomeRowLoadingState.Error(title, exception = ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List<BaseItemDto> {
|
||||
val base =
|
||||
api.baseUrl?.toHttpUrlOrNull()
|
||||
?: throw IllegalStateException("Jellyfin baseUrl not set")
|
||||
val endpointUri = row.endpoint.toUri()
|
||||
if (!endpointUri.isRelative) {
|
||||
throw IllegalArgumentException("Custom endpoint must be an path relative to Jellyfin baseUrl: ${row.endpoint}")
|
||||
}
|
||||
val params =
|
||||
buildMap {
|
||||
serverRepository.currentUser
|
||||
?.id
|
||||
?.toString()
|
||||
?.let { put("userId", it) }
|
||||
row.query?.forEach { put(it.key, it.value) }
|
||||
}
|
||||
val resolved =
|
||||
base
|
||||
.resolve(row.endpoint)
|
||||
?.newBuilder()
|
||||
?.apply {
|
||||
params.forEach { (k, v) -> addQueryParameter(k, v) }
|
||||
}?.build()
|
||||
?: throw IllegalStateException("Could not resolve endpoint: ${row.endpoint}")
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(resolved)
|
||||
.get()
|
||||
.apply {
|
||||
row.headers?.forEach { header(it.key, it.value) }
|
||||
}.build()
|
||||
val response =
|
||||
authOkHttpClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
return response.use {
|
||||
if (!it.isSuccessful) {
|
||||
throw InvalidStatusException(it.code, null)
|
||||
}
|
||||
val queryResult =
|
||||
HomeSettingsService.jsonParser.decodeFromStream<BaseItemDtoQueryResult>(it.body.byteStream())
|
||||
Timber.v("Got %s items from custom endpoint", queryResult.items.size)
|
||||
queryResult.items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val Library?.itemFields: List<ItemFields>
|
||||
get() =
|
||||
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
|
||||
// Get child count to show in the header
|
||||
HomeItemFieldsBoxSets
|
||||
} else {
|
||||
HomeItemFields
|
||||
}
|
||||
|
||||
private val Library.includeItemTypes: List<BaseItemKind>?
|
||||
get() =
|
||||
collectionType.let { collectionType ->
|
||||
getTypeFor(collectionType)?.let { type -> listOf(type) }
|
||||
}
|
||||
|
||||
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
|
||||
|
|
@ -4,47 +4,25 @@ import android.content.Context
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.HomeItemFields
|
||||
import com.github.damontecres.wholphin.ui.ProgramItemFields
|
||||
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
|
||||
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.playback.getTypeFor
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
|
||||
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 com.github.damontecres.wholphin.ui.util.StringStringProvider
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetArtistsHandler
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
|
||||
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
|
|
@ -56,32 +34,15 @@ import kotlinx.serialization.json.jsonArray
|
|||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.DateTime
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Handles getting home page settings and data
|
||||
|
|
@ -93,21 +54,11 @@ class HomeSettingsService
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val suggestionService: SuggestionService,
|
||||
private val displayPreferencesService: DisplayPreferencesService,
|
||||
private val serverPluginApi: ServerPluginApi,
|
||||
private val serverDao: JellyfinServerDao,
|
||||
) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val jsonParser =
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
allowTrailingComma = true
|
||||
}
|
||||
|
||||
/**
|
||||
* The current home page settings
|
||||
*/
|
||||
|
|
@ -211,6 +162,42 @@ class HomeSettingsService
|
|||
return HomePageSettings(rows, version)
|
||||
}
|
||||
|
||||
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
|
||||
try {
|
||||
block.invoke()
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error loading settings")
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun fetchSettingsFor(
|
||||
userId: UUID,
|
||||
source: HomePageSettingsSource,
|
||||
): HomePageResolvedSettings? {
|
||||
val (resolvedSource, settings) =
|
||||
when (source) {
|
||||
HomePageSettingsSource.UNSET -> loadInOrder(userId)
|
||||
HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId)
|
||||
HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId)
|
||||
HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings()
|
||||
HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings()
|
||||
HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings()
|
||||
}
|
||||
Timber.i(
|
||||
"fetch home settings: resolvedSource=%s, userId=%s, found=%s",
|
||||
resolvedSource,
|
||||
userId,
|
||||
settings != null,
|
||||
)
|
||||
return settings?.let {
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads [HomePageSettings] into [currentSettings]
|
||||
*
|
||||
|
|
@ -218,55 +205,70 @@ class HomeSettingsService
|
|||
*
|
||||
* Does not persist either the server nor default
|
||||
*/
|
||||
suspend fun loadCurrentSettings(userId: UUID) {
|
||||
Timber.v("Getting setting for %s", userId)
|
||||
// User local then server/remote otherwise create a default
|
||||
suspend fun loadCurrentSettings(user: JellyfinUser) {
|
||||
currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||
|
||||
Timber.v("Getting setting for %s", user.id)
|
||||
val settings =
|
||||
try {
|
||||
val local = loadFromLocal(userId)
|
||||
Timber.v("Found local? %s", local != null)
|
||||
local
|
||||
fetchSettingsFor(user.id, user.config.homeSettingsSource)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error loading local settings")
|
||||
// TODO show toast?
|
||||
null
|
||||
} ?: try {
|
||||
val remote = loadFromServer(userId)
|
||||
Timber.v("Found remote? %s", remote != null)
|
||||
remote
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error loading remote settings")
|
||||
Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource)
|
||||
null
|
||||
}
|
||||
val resolvedSettings =
|
||||
if (settings != null) {
|
||||
Timber.v("Found settings")
|
||||
// Resolve
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
HomePageResolvedSettings(userId, resolvedRows)
|
||||
} else {
|
||||
createDefault(userId)
|
||||
}
|
||||
|
||||
Timber.v("Found settings: %s", settings != null)
|
||||
val resolvedSettings = settings ?: createDefault(user.id)
|
||||
currentSettings.update { resolvedSettings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to load settings from local->server->plugin
|
||||
*/
|
||||
private suspend fun loadInOrder(userId: UUID): Pair<HomePageSettingsSource, HomePageSettings?> {
|
||||
Timber.v("loadInOrder: local")
|
||||
var settings =
|
||||
HomePageSettingsSource.LOCAL to
|
||||
tryLoad {
|
||||
loadFromLocal(userId)
|
||||
}
|
||||
|
||||
if (settings.second == null) {
|
||||
Timber.v("loadInOrder: server")
|
||||
settings =
|
||||
HomePageSettingsSource.SERVER_PROFILE to
|
||||
tryLoad {
|
||||
loadFromServer(userId)
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.second == null && serverRepository.serverPluginInstalled.value) {
|
||||
Timber.v("loadInOrder: plugin")
|
||||
settings = HomePageSettingsSource.PLUGIN to
|
||||
tryLoad {
|
||||
serverPluginApi.fetchHomePageSettings()
|
||||
}
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the settings and set them to be the current settings
|
||||
*/
|
||||
suspend fun updateCurrent(
|
||||
userId: UUID,
|
||||
source: HomePageSettingsSource,
|
||||
settings: HomePageSettings,
|
||||
) {
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
|
||||
currentSettings.update { resolvedSettings }
|
||||
currentSettings.update { it.copy(rows = resolvedRows) }
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val toSave = user.updateConfig { it.copy(homeSettingsSource = source) }
|
||||
serverDao.updateUser(toSave)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -316,7 +318,7 @@ class HomeSettingsService
|
|||
),
|
||||
)
|
||||
val rowConfig = continueWatchingRow + includedIds
|
||||
return HomePageResolvedSettings(userId, rowConfig)
|
||||
return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -432,7 +434,7 @@ class HomeSettingsService
|
|||
null
|
||||
}
|
||||
}.flatten()
|
||||
HomePageResolvedSettings(userId, rowConfigs)
|
||||
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -560,6 +562,14 @@ class HomeSettingsService
|
|||
config,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.CustomEndpoint -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = StringStringProvider(config.title),
|
||||
config,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getItemName(
|
||||
|
|
@ -585,632 +595,16 @@ class HomeSettingsService
|
|||
ResStringProvider(R.string.unknown)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the data from the server for a given [HomeRowConfig]
|
||||
*/
|
||||
suspend fun fetchDataForRow(
|
||||
row: HomeRowConfig,
|
||||
scope: CoroutineScope,
|
||||
prefs: HomePagePreferences,
|
||||
userDto: ServerUserConfig,
|
||||
libraries: List<Library>,
|
||||
limit: Int = prefs.maxItemsPerRow,
|
||||
isRefresh: Boolean,
|
||||
usePaging: Boolean = false,
|
||||
): HomeRowLoadingState =
|
||||
when (row) {
|
||||
is HomeRowConfig.ContinueWatching -> {
|
||||
val resume =
|
||||
latestNextUpService.getResume(
|
||||
userDto.id,
|
||||
limit,
|
||||
true,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
items = resume,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = resume.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.NextUp -> {
|
||||
val nextUp =
|
||||
latestNextUpService.getNextUp(
|
||||
userDto.id,
|
||||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.next_up),
|
||||
items = nextUp,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = nextUp.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.ContinueWatchingCombined -> {
|
||||
val resume =
|
||||
latestNextUpService.getResume(
|
||||
userDto.id,
|
||||
limit,
|
||||
true,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
val nextUp =
|
||||
latestNextUpService.getNextUp(
|
||||
userDto.id,
|
||||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
val combined = latestNextUpService.buildCombined(resume, nextUp)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
items = combined.take(limit),
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = combined.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.Genres -> {
|
||||
val request =
|
||||
GetGenresRequest(
|
||||
parentId = row.parentId,
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
)
|
||||
val items =
|
||||
GetGenresRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
val genreIds = items.map { it.id }
|
||||
val genreImages =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
scope = scope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genreIds,
|
||||
parentId = row.parentId,
|
||||
includeItemTypes = null,
|
||||
cardWidthPx = null,
|
||||
useCache = isRefresh,
|
||||
)
|
||||
val library =
|
||||
libraries
|
||||
.firstOrNull { it.itemId == row.parentId }
|
||||
|
||||
val title =
|
||||
library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
|
||||
?: ResStringProvider(R.string.genres)
|
||||
val genres =
|
||||
items.map {
|
||||
BaseItem(
|
||||
it,
|
||||
false,
|
||||
genreImages[it.id],
|
||||
createGenreDestination(
|
||||
genreId = it.id,
|
||||
genreName = it.name ?: "",
|
||||
parentId = row.parentId,
|
||||
parentName = library?.name,
|
||||
includeItemTypes =
|
||||
library?.collectionType?.let {
|
||||
getTypeFor(it)?.let {
|
||||
listOf(it)
|
||||
}
|
||||
},
|
||||
collectionType =
|
||||
library?.collectionType
|
||||
?: CollectionType.UNKNOWN,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Success(
|
||||
title,
|
||||
genres,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = genres.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.Studios -> {
|
||||
val request =
|
||||
GetStudiosRequest(
|
||||
parentId = row.parentId,
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||
)
|
||||
val items =
|
||||
GetStudiosRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
val library =
|
||||
libraries
|
||||
.firstOrNull { it.itemId == row.parentId }
|
||||
val title =
|
||||
library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
|
||||
?: ResStringProvider(R.string.studios)
|
||||
val studios =
|
||||
items.map {
|
||||
val imageUrl =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = it.id,
|
||||
imageType = ImageType.THUMB,
|
||||
tag = it.imageTags?.get(ImageType.THUMB),
|
||||
)
|
||||
BaseItem(
|
||||
it,
|
||||
false,
|
||||
imageUrl,
|
||||
createStudioDestination(
|
||||
studioId = it.id,
|
||||
name = it.name ?: "",
|
||||
parentId = row.parentId,
|
||||
parentName = library?.name,
|
||||
includeItemTypes =
|
||||
library?.collectionType?.let {
|
||||
getTypeFor(it)?.let {
|
||||
listOf(it)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Success(
|
||||
title,
|
||||
studios,
|
||||
viewOptions = row.viewOptions,
|
||||
showViewMore = studios.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.RecentlyAdded -> {
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val title = getRecentlyAddedTitle(library?.name)
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = library.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = row.parentId,
|
||||
groupItems = true,
|
||||
limit = limit,
|
||||
isPlayed = null, // Server will handle user's preference
|
||||
)
|
||||
val latest =
|
||||
api.userLibraryApi
|
||||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
is HomeRowConfig.RecentlyReleased -> {
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val title =
|
||||
library?.name?.let {
|
||||
ResArgStringProvider(R.string.recently_released_in, it)
|
||||
} ?: ResStringProvider(R.string.recently_released)
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = row.parentId,
|
||||
limit = limit,
|
||||
sortBy =
|
||||
listOf(
|
||||
ItemSortBy.PREMIERE_DATE,
|
||||
ItemSortBy.SERIES_SORT_NAME,
|
||||
ItemSortBy.AIRED_EPISODE_ORDER,
|
||||
),
|
||||
sortOrder =
|
||||
listOf(
|
||||
SortOrder.DESCENDING,
|
||||
SortOrder.ASCENDING,
|
||||
SortOrder.DESCENDING,
|
||||
),
|
||||
fields = library.itemFields,
|
||||
recursive = true,
|
||||
maxPremiereDate = LocalDateTime.now(),
|
||||
isUnaired = false,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem.from(it, api, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.ByParent -> {
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = userDto.id,
|
||||
parentId = row.parentId,
|
||||
recursive = row.recursive,
|
||||
sortBy =
|
||||
row.sort?.let {
|
||||
buildList {
|
||||
if (it.sort == ItemSortBy.RANDOM) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
add(ItemSortBy.RANDOM)
|
||||
} else {
|
||||
add(it.sort)
|
||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
sortOrder =
|
||||
row.sort?.let {
|
||||
buildList {
|
||||
if (it.sort == ItemSortBy.RANDOM) {
|
||||
add(SortOrder.ASCENDING)
|
||||
add(it.direction)
|
||||
} else {
|
||||
add(it.direction)
|
||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
limit = limit,
|
||||
fields = library.itemFields,
|
||||
)
|
||||
|
||||
// Not using getItemName because we want to throw the 404
|
||||
val title =
|
||||
api.userLibraryApi
|
||||
.getItem(
|
||||
userId = serverRepository.currentUser?.id,
|
||||
itemId = row.parentId,
|
||||
).content.name
|
||||
?.let { StringStringProvider(it) }
|
||||
?: ResStringProvider(R.string.collection)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.GetItems -> {
|
||||
val request =
|
||||
row.getItems.let {
|
||||
if (it.limit == null) {
|
||||
it.copy(
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
)
|
||||
} else {
|
||||
it.copy(
|
||||
userId = userDto.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
StringStringProvider(row.name),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.Favorite -> {
|
||||
val title =
|
||||
ResProviderStringProvider(
|
||||
R.string.favorite_items_title,
|
||||
ResStringProvider(formatTypeName(row.kind)),
|
||||
)
|
||||
val resultList =
|
||||
when (row.kind) {
|
||||
BaseItemKind.PERSON -> {
|
||||
val request =
|
||||
GetPersonsRequest(
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
fields = HomeItemFields,
|
||||
isFavorite = true,
|
||||
enableImages = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
)
|
||||
|
||||
GetPersonsHandler.execute(api, request).toBaseItems()
|
||||
}
|
||||
|
||||
BaseItemKind.MUSIC_ARTIST -> {
|
||||
val request =
|
||||
GetArtistsRequest(
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
fields = HomeItemFields,
|
||||
isFavorite = true,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetArtistsHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetArtistsHandler
|
||||
.execute(api, request)
|
||||
.toBaseItems(row.viewOptions.useSeries)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val fields =
|
||||
if (row.kind == BaseItemKind.BOX_SET) {
|
||||
HomeItemFieldsBoxSets
|
||||
} else {
|
||||
HomeItemFields
|
||||
}
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = userDto.id,
|
||||
recursive = true,
|
||||
limit = limit,
|
||||
fields = fields,
|
||||
includeItemTypes = listOf(row.kind),
|
||||
isFavorite = true,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.toBaseItems(row.viewOptions.useSeries)
|
||||
}
|
||||
}
|
||||
}
|
||||
resultList.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.Recordings -> {
|
||||
val request =
|
||||
GetRecordingsRequest(
|
||||
userId = userDto.id,
|
||||
isInProgress = true,
|
||||
fields = HomeItemFields,
|
||||
limit = limit,
|
||||
enableImages = true,
|
||||
enableUserData = true,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetRecordingsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
api.liveTvApi
|
||||
.getRecordings(request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
ResStringProvider(R.string.active_recordings),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.TvPrograms -> {
|
||||
val request =
|
||||
GetProgramsDto(
|
||||
userId = userDto.id,
|
||||
fields = ProgramItemFields,
|
||||
limit = limit,
|
||||
enableUserData = true,
|
||||
enableImages = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
||||
imageTypeLimit = 1,
|
||||
isAiring = true,
|
||||
minEndDate = DateTime.now().plusMinutes(1),
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetProgramsDtoHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
api.liveTvApi
|
||||
.getPrograms(request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
ResStringProvider(R.string.watch_live),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.TvChannels -> {
|
||||
val request =
|
||||
GetLiveTvChannelsRequest(
|
||||
userId = userDto.id,
|
||||
fields = HomeItemFields,
|
||||
limit = limit,
|
||||
enableImages = true,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetLiveTvChannelsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
api.liveTvApi
|
||||
.getLiveTvChannels(request)
|
||||
.toBaseItems(api, row.viewOptions.useSeries)
|
||||
}.let {
|
||||
Success(
|
||||
ResStringProvider(R.string.channels),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.Suggestions -> {
|
||||
val library =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = row.parentId)
|
||||
.content
|
||||
val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
|
||||
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
|
||||
if (itemKind != null) {
|
||||
val suggestions =
|
||||
suggestionService
|
||||
.getSuggestionsFlow(row.parentId, itemKind)
|
||||
.firstOrNull()
|
||||
when (suggestions) {
|
||||
SuggestionsResource.Empty -> {
|
||||
Success(
|
||||
title,
|
||||
listOf(),
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Success -> {
|
||||
Success(
|
||||
title,
|
||||
suggestions.items,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = suggestions.items.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
SuggestionsResource.Loading,
|
||||
null,
|
||||
-> {
|
||||
HomeRowLoadingState.Loading(title)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HomeRowLoadingState.Error(
|
||||
title = title,
|
||||
message = "Unsupported type ${library.collectionType}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CUSTOM_PREF_ID = "home_settings"
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val jsonParser =
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
allowTrailingComma = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1230,10 +624,14 @@ data class HomeRowConfigDisplay(
|
|||
*/
|
||||
data class HomePageResolvedSettings(
|
||||
val userId: UUID,
|
||||
val source: HomePageSettingsSource,
|
||||
val rows: List<HomeRowConfigDisplay>,
|
||||
) {
|
||||
fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||
|
||||
companion object {
|
||||
val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
|
||||
val EMPTY =
|
||||
HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1267,17 +665,6 @@ fun getRecentlyAddedTitle(name: String?): StringProvider =
|
|||
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
|
||||
?: ResStringProvider(R.string.recently_added)
|
||||
|
||||
private val Library?.itemFields: List<ItemFields>
|
||||
get() =
|
||||
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
|
||||
// Get child count to show in the header
|
||||
HomeItemFieldsBoxSets
|
||||
} else {
|
||||
HomeItemFields
|
||||
}
|
||||
|
||||
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
|
||||
|
||||
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
|
||||
when (collectionType) {
|
||||
CollectionType.MUSIC,
|
||||
|
|
@ -1316,3 +703,14 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp
|
|||
HomeRowViewOptions()
|
||||
}
|
||||
}
|
||||
|
||||
enum class HomePageSettingsSource(
|
||||
@param:StringRes val stringResId: Int,
|
||||
) {
|
||||
UNSET(R.string.unknown),
|
||||
LOCAL(R.string.home_settings_source_local),
|
||||
SERVER_PROFILE(R.string.home_settings_source_server_profile),
|
||||
PLUGIN(R.string.home_settings_source_plugin),
|
||||
DEFAULT(R.string.home_settings_source_unset),
|
||||
WEB_CONFIG(R.string.home_settings_source_web),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.IOException
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Service for interacting with the companion plugin
|
||||
*/
|
||||
@Singleton
|
||||
class ServerPluginApi
|
||||
@Inject
|
||||
constructor(
|
||||
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
private fun createUrl(path: String): String? =
|
||||
api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" }
|
||||
|
||||
private val json get() = HomeSettingsService.jsonParser
|
||||
|
||||
companion object {
|
||||
private const val HOME_CONFIG_PATH = "homesettings"
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the plugin is installed on the server
|
||||
*/
|
||||
suspend fun checkInstalled(): Boolean =
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val url = createUrl("public") ?: return@withContext false
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
try {
|
||||
return@withContext okHttpClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { it.isSuccessful }
|
||||
} catch (ex: IOException) {
|
||||
Timber.w("Could not connect to server plugin: %s", ex.message)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default home page settings from the plugin
|
||||
*
|
||||
* @return the home page settings or null if no rows are configured or the server isn't installed
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun fetchHomePageSettings(): HomePageSettings? =
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val url = createUrl(HOME_CONFIG_PATH) ?: return@withContext null
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
return@withContext okHttpClient.newCall(request).execute().use { res ->
|
||||
if (res.isSuccessful) {
|
||||
json
|
||||
.decodeFromStream<HomePageSettings>(res.body.byteStream())
|
||||
.takeIf { it.rows.isNotEmpty() }
|
||||
} else if (res.code == 404) {
|
||||
Timber.w("fetchHomePageSettings returned 404")
|
||||
null
|
||||
} else {
|
||||
throw ApiClientException(res.code.toString() + " " + res.body.string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import kotlinx.coroutines.supervisorScope
|
|||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Listens for JF user switching in the app to also switch other settings like Seerr user/server
|
||||
|
|
@ -36,6 +37,7 @@ class UserSwitchListener
|
|||
private val seerrServerDao: SeerrServerDao,
|
||||
private val seerrApi: SeerrApi,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val serverPluginApi: ServerPluginApi,
|
||||
) {
|
||||
init {
|
||||
context as AppCompatActivity
|
||||
|
|
@ -65,9 +67,22 @@ class UserSwitchListener
|
|||
AppCompatDelegate.setApplicationLocales(localeList)
|
||||
}
|
||||
|
||||
// Check for home settings
|
||||
// Check if plugin is installed, then for home settings
|
||||
launchIO {
|
||||
homeSettingsService.loadCurrentSettings(user.id)
|
||||
val serverPluginInstalled =
|
||||
try {
|
||||
serverPluginApi.checkInstalled()
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error checking for server plugin")
|
||||
false
|
||||
}
|
||||
Timber.i("Server plugin installed: %s", serverPluginInstalled)
|
||||
serverRepository.serverPluginInstalled.value = serverPluginInstalled
|
||||
|
||||
// Check for home settings
|
||||
homeSettingsService.loadCurrentSettings(user)
|
||||
}
|
||||
if (BuildConfig.DISCOVER_ENABLED) {
|
||||
// Check for seerr server
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.HomeDataService
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
|
|
@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
|||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.services.tvAccess
|
||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -75,7 +74,7 @@ class HomeRowGridViewModel
|
|||
val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val homeDataService: HomeDataService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
private val navigationManager: NavigationManager,
|
||||
|
|
@ -128,7 +127,7 @@ class HomeRowGridViewModel
|
|||
val libraries =
|
||||
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||
val result =
|
||||
homeSettingsService.fetchDataForRow(
|
||||
homeDataService.fetchDataForRow(
|
||||
row = rowConfig,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.HomeDataService
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
|
|
@ -62,6 +63,7 @@ class HomeViewModel
|
|||
val serverReportService: ServerReportService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val homeDataService: HomeDataService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
private val backdropService: BackdropService,
|
||||
|
|
@ -186,7 +188,7 @@ class HomeViewModel
|
|||
semaphore.withPermit {
|
||||
Timber.v("Fetching row: %s", row)
|
||||
try {
|
||||
homeSettingsService.fetchDataForRow(
|
||||
homeDataService.fetchDataForRow(
|
||||
row = row.config,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey {
|
|||
@Serializable
|
||||
data object GlobalSettings : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object SourceSettings : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object Presets : HomeSettingsDestination
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,6 @@ fun HomeSettingsGlobal(
|
|||
preferences: AppPreferences,
|
||||
onPreferenceChange: (AppPreferences) -> Unit,
|
||||
onClickResize: (Int) -> Unit,
|
||||
onClickSave: () -> Unit,
|
||||
onClickLoad: () -> Unit,
|
||||
onClickLoadWeb: () -> Unit,
|
||||
onClickReset: () -> Unit,
|
||||
onClickViewNextUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -146,62 +142,6 @@ fun HomeSettingsGlobal(
|
|||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.save_to_server),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_cloud_arrow_up),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickSave,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_server),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_cloud_arrow_down),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoad,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_web_client),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_download),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoadWeb,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.reset),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_arrows_rotate),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickReset,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -64,11 +65,14 @@ fun HomeSettingsPage(
|
|||
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
|
||||
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
|
||||
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
|
||||
var showSaveDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val state by viewModel.state.collectAsState()
|
||||
val serverPluginActive by viewModel.serverPluginActive.collectAsState()
|
||||
var position by rememberPosition(0, 0)
|
||||
// TODO discover rows
|
||||
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
|
||||
val settingsHaveChanged by viewModel.settingsChanged.collectAsState()
|
||||
|
||||
// Adds a row, waits until its done loading, then scrolls to the new row
|
||||
fun addRow(
|
||||
|
|
@ -86,6 +90,10 @@ fun HomeSettingsPage(
|
|||
}
|
||||
}
|
||||
|
||||
BackHandler(settingsHaveChanged) {
|
||||
showSaveDialog = true
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
|
|
@ -137,6 +145,7 @@ fun HomeSettingsPage(
|
|||
position = RowColumn(index, 0)
|
||||
}
|
||||
},
|
||||
onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) },
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -282,6 +291,16 @@ fun HomeSettingsPage(
|
|||
}
|
||||
},
|
||||
onClickResize = { viewModel.resizeCards(it) },
|
||||
onClickViewNextUp = {
|
||||
showRemovedNextUpDialog = true
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
||||
HomeSettingsDestination.SourceSettings -> {
|
||||
HomeSettingsSourcePage(
|
||||
source = state.source,
|
||||
onClickSave = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_server_settings) {
|
||||
|
|
@ -294,6 +313,13 @@ fun HomeSettingsPage(
|
|||
viewModel.loadFromRemote()
|
||||
}
|
||||
},
|
||||
serverPluginActive = serverPluginActive,
|
||||
onClickLoadPlugin = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_local_settings) {
|
||||
viewModel.loadFromRemotePlugin()
|
||||
}
|
||||
},
|
||||
onClickLoadWeb = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_local_settings) {
|
||||
|
|
@ -306,9 +332,6 @@ fun HomeSettingsPage(
|
|||
addRow(false) { viewModel.resetToDefault() }
|
||||
}
|
||||
},
|
||||
onClickViewNextUp = {
|
||||
showRemovedNextUpDialog = true
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -375,6 +398,29 @@ fun HomeSettingsPage(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (showSaveDialog) {
|
||||
SaveHomeSettingsDialog(
|
||||
onDismissRequest = { showSaveDialog = false },
|
||||
onSaveLocal = {
|
||||
scope.launch {
|
||||
viewModel.saveToLocal().join()
|
||||
viewModel.navigationManager.goBack()
|
||||
}
|
||||
},
|
||||
onSaveServer = {
|
||||
scope.launch {
|
||||
viewModel.saveToRemote().join()
|
||||
viewModel.navigationManager.goBack()
|
||||
}
|
||||
},
|
||||
onDiscard = {
|
||||
scope.launch {
|
||||
viewModel.discardChanges().join()
|
||||
viewModel.navigationManager.goBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ShowConfirm(
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ enum class MoveDirection {
|
|||
DOWN,
|
||||
}
|
||||
|
||||
private const val POS_ADD_ROW = 0
|
||||
private const val POS_SOURCE = POS_ADD_ROW + 1
|
||||
private const val POS_SETTINGS = POS_SOURCE + 1
|
||||
private const val POS_PRESETS = POS_SETTINGS + 1
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsRowList(
|
||||
state: HomePageSettingsState,
|
||||
|
|
@ -58,13 +63,14 @@ fun HomeSettingsRowList(
|
|||
onClickPresets: () -> Unit,
|
||||
onClickMove: (MoveDirection, Int) -> Unit,
|
||||
onClickDelete: (Int) -> Unit,
|
||||
onClickSourceSettings: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val itemsBeforeRows = 4
|
||||
val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider
|
||||
val focusRequesters =
|
||||
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
|
||||
|
||||
|
|
@ -80,7 +86,7 @@ fun HomeSettingsRowList(
|
|||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(focusRequesters[0]),
|
||||
.focusRestorer(focusRequesters[POS_ADD_ROW]),
|
||||
) {
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
|
|
@ -93,10 +99,30 @@ fun HomeSettingsRowList(
|
|||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 0
|
||||
position = POS_ADD_ROW
|
||||
onClickAdd.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[0]),
|
||||
modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = "Source",
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_download),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = stringResource(state.source.stringResId))
|
||||
},
|
||||
onClick = {
|
||||
position = POS_SOURCE
|
||||
onClickSourceSettings.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
@ -110,10 +136,10 @@ fun HomeSettingsRowList(
|
|||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 1
|
||||
position = POS_SETTINGS
|
||||
onClickSettings.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[1]),
|
||||
modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
@ -132,10 +158,10 @@ fun HomeSettingsRowList(
|
|||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 2
|
||||
position = POS_PRESETS
|
||||
onClickPresets.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[2]),
|
||||
modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.services.HomePageSettingsSource
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsSourcePage(
|
||||
source: HomePageSettingsSource,
|
||||
onClickSave: () -> Unit,
|
||||
onClickLoad: () -> Unit,
|
||||
onClickLoadWeb: () -> Unit,
|
||||
onClickReset: () -> Unit,
|
||||
serverPluginActive: Boolean,
|
||||
onClickLoadPlugin: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val firstFocus: FocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_server),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_cloud_arrow_down),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoad,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
if (serverPluginActive) {
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_server_plugin),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_download),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoadPlugin,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_web_client),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_download),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoadWeb,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.reset),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_arrows_rotate),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickReset,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,16 +24,18 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
|||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.HomeDataService
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomePageSettingsSource
|
||||
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.ServerPluginApi
|
||||
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import com.github.damontecres.wholphin.services.tvAccess
|
||||
import com.github.damontecres.wholphin.services.viewOptionsForCollectionType
|
||||
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
|
|
@ -48,13 +50,16 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
|
@ -76,37 +81,55 @@ class HomeSettingsViewModel
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val homeDataService: HomeDataService,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val backdropService: BackdropService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val serverPluginApi: ServerPluginApi,
|
||||
val preferencesDataStore: DataStore<AppPreferences>,
|
||||
@param:IoCoroutineScope private val ioScope: CoroutineScope,
|
||||
val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
|
||||
val state: StateFlow<HomePageSettingsState> = _state
|
||||
|
||||
val serverPluginActive get() = serverRepository.serverPluginInstalled
|
||||
val currentUser
|
||||
get() =
|
||||
serverRepository.currentUserFlow.filterNotNull().stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
serverRepository.currentUser!!,
|
||||
)
|
||||
val settingsChanged =
|
||||
state
|
||||
.map {
|
||||
HomePageSettings(
|
||||
rows = it.rows.map { it.config },
|
||||
version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
|
||||
) != originalSettings
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
private var idCounter by Delegates.notNull<Int>()
|
||||
|
||||
val discoverEnabled = seerrServerRepository.active
|
||||
|
||||
private var originalLocalSettings: HomePageSettings? = null
|
||||
private var originalRemoteSettings: HomePageSettings? = null
|
||||
private var originalSettings: HomePageSettings? = null
|
||||
|
||||
init {
|
||||
addCloseable { saveToLocal() }
|
||||
viewModelScope.launchIO {
|
||||
val user = serverRepository.currentUser ?: return@launchIO
|
||||
val userDto = serverRepository.currentUserDto ?: return@launchIO
|
||||
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||
val currentSettings =
|
||||
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||
originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
|
||||
originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
|
||||
originalSettings = currentSettings.asHomePageSettings()
|
||||
Timber.v("currentSettings=%s", currentSettings)
|
||||
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(
|
||||
source = currentSettings.source,
|
||||
libraries = libraries,
|
||||
rows = currentSettings.rows,
|
||||
)
|
||||
|
|
@ -134,7 +157,7 @@ class HomeSettingsViewModel
|
|||
viewModelScope.async(WholphinDispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
try {
|
||||
homeSettingsService.fetchDataForRow(
|
||||
homeDataService.fetchDataForRow(
|
||||
row = row.config,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
|
|
@ -496,7 +519,7 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun saveToRemote() {
|
||||
fun saveToRemote(): Job =
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser?.let { user ->
|
||||
Timber.d("Saving home settings to remote")
|
||||
|
|
@ -506,6 +529,10 @@ class HomeSettingsViewModel
|
|||
try {
|
||||
Timber.d("saveToRemote")
|
||||
homeSettingsService.saveToServer(user.id, settings)
|
||||
homeSettingsService.updateCurrent(
|
||||
HomePageSettingsSource.SERVER_PROFILE,
|
||||
settings,
|
||||
)
|
||||
showSaveToast()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
|
|
@ -513,7 +540,6 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFromRemote() {
|
||||
viewModelScope.launchIO {
|
||||
|
|
@ -530,7 +556,10 @@ class HomeSettingsViewModel
|
|||
}
|
||||
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(rows = newRows)
|
||||
it.copy(
|
||||
source = HomePageSettingsSource.SERVER_PROFILE,
|
||||
rows = newRows,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No remote settings")
|
||||
|
|
@ -560,7 +589,10 @@ class HomeSettingsViewModel
|
|||
Timber.v("Got web settings")
|
||||
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(rows = result.rows)
|
||||
it.copy(
|
||||
source = HomePageSettingsSource.LOCAL,
|
||||
rows = result.rows,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No web settings")
|
||||
|
|
@ -575,34 +607,19 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun saveToLocal() {
|
||||
// This uses injected ioScope so that it will still run when the page is closing
|
||||
ioScope.launchIO {
|
||||
fun saveToLocal(): Job =
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val rows = state.value.rows.map { it.config }
|
||||
val settings =
|
||||
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||
try {
|
||||
Timber.d("saveToLocal")
|
||||
// Only save if there are changes based on original source
|
||||
val shouldSave =
|
||||
if (originalLocalSettings != null) {
|
||||
originalLocalSettings != settings
|
||||
} else if (originalRemoteSettings != null) {
|
||||
originalRemoteSettings != settings
|
||||
} else {
|
||||
true
|
||||
}
|
||||
if (shouldSave) {
|
||||
homeSettingsService.saveToLocal(user.id, settings)
|
||||
homeSettingsService.updateCurrent(user.id, settings)
|
||||
showSaveToast()
|
||||
} else {
|
||||
Timber.d("No changes")
|
||||
}
|
||||
} catch (ex: UnsupportedHomeSettingsVersionException) {
|
||||
Timber.w(ex, "Overwriting local settings")
|
||||
homeSettingsService.saveToLocal(user.id, settings)
|
||||
homeSettingsService.updateCurrent(
|
||||
HomePageSettingsSource.LOCAL,
|
||||
settings,
|
||||
)
|
||||
showSaveToast()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
|
|
@ -610,17 +627,11 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
|
||||
_state.update {
|
||||
update.invoke(it)
|
||||
}
|
||||
serverRepository.currentUser?.id?.let { userId ->
|
||||
homeSettingsService.currentSettings.update {
|
||||
HomePageResolvedSettings(userId, state.value.rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resizeCards(relative: Int) {
|
||||
|
|
@ -649,12 +660,32 @@ class HomeSettingsViewModel
|
|||
|
||||
fun resetToDefault() =
|
||||
viewModelScope.launchIO {
|
||||
val userId = serverRepository.currentUser?.id ?: return@launchIO
|
||||
val user = serverRepository.currentUser ?: return@launchIO
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
val result = homeSettingsService.createDefault(userId)
|
||||
val result =
|
||||
if (serverPluginActive.value) {
|
||||
try {
|
||||
val settings =
|
||||
homeSettingsService.fetchSettingsFor(
|
||||
user.id,
|
||||
HomePageSettingsSource.PLUGIN,
|
||||
)
|
||||
settings
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Plugin is active, but error fetching home settings")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
} ?: homeSettingsService.createDefault(user.id)
|
||||
|
||||
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(rows = result.rows, rowData = emptyList())
|
||||
it.copy(
|
||||
source = result.source,
|
||||
rows = result.rows,
|
||||
rowData = emptyList(),
|
||||
)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
|
|
@ -746,6 +777,10 @@ class HomeSettingsViewModel
|
|||
is HomeRowConfig.TvChannels -> {
|
||||
it.config.updateViewOptions(preset.liveTv)
|
||||
}
|
||||
|
||||
is HomeRowConfig.CustomEndpoint -> {
|
||||
it.config
|
||||
}
|
||||
}
|
||||
it.copy(config = newConfig)
|
||||
}
|
||||
|
|
@ -767,6 +802,43 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun loadFromRemotePlugin() {
|
||||
viewModelScope.launchIO {
|
||||
Timber.d("Loading home settings from server plugin")
|
||||
try {
|
||||
// TODO should this remove local settings?
|
||||
// TODO how to set up priorities
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
val result = serverPluginApi.fetchHomePageSettings()
|
||||
if (result != null) {
|
||||
Timber.v("Got remote settings")
|
||||
val newRows =
|
||||
result.rows.mapIndexed { index, config ->
|
||||
homeSettingsService.resolve(index, config)
|
||||
}
|
||||
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(
|
||||
source = HomePageSettingsSource.PLUGIN,
|
||||
rows = newRows,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No server plugin settings")
|
||||
showToast(context, "No server plugin settings found")
|
||||
}
|
||||
fetchRowData()
|
||||
} catch (ex: UnsupportedHomeSettingsVersionException) {
|
||||
// TODO
|
||||
Timber.w(ex)
|
||||
showToast(context, "Error: ${ex.localizedMessage}")
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
showToast(context, "Error: ${ex.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onConfigAction(
|
||||
row: HomeRowConfigDisplay,
|
||||
action: HomeRowConfigAction,
|
||||
|
|
@ -861,10 +933,13 @@ class HomeSettingsViewModel
|
|||
fetchRowData()
|
||||
}
|
||||
}
|
||||
|
||||
fun discardChanges(): Job = Job().apply { complete() }
|
||||
}
|
||||
|
||||
data class HomePageSettingsState(
|
||||
val loading: LoadingState,
|
||||
val source: HomePageSettingsSource,
|
||||
val rows: List<HomeRowConfigDisplay>,
|
||||
val rowData: List<HomeRowLoadingState>,
|
||||
val libraries: List<Library>,
|
||||
|
|
@ -873,6 +948,7 @@ data class HomePageSettingsState(
|
|||
val EMPTY =
|
||||
HomePageSettingsState(
|
||||
LoadingState.Pending,
|
||||
HomePageSettingsSource.UNSET,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
|
||||
@Composable
|
||||
fun SaveHomeSettingsDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onSaveLocal: () -> Unit,
|
||||
onSaveServer: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
val params =
|
||||
remember(resources) {
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = resources.getString(R.string.save),
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
text = R.string.save_to_local,
|
||||
iconStringRes = R.string.fa_cloud_arrow_down,
|
||||
onClick = onSaveLocal,
|
||||
),
|
||||
DialogItem(
|
||||
text = R.string.save_to_server,
|
||||
iconStringRes = R.string.fa_cloud_arrow_up,
|
||||
onClick = onSaveServer,
|
||||
),
|
||||
DialogItem(
|
||||
text = "Discard",
|
||||
icon = Icons.Default.Delete,
|
||||
iconColor = Color.Red,
|
||||
onClick = onDiscard,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
DialogPopup(
|
||||
params = params,
|
||||
onDismissRequest = onDismissRequest,
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
|
|
@ -967,4 +967,11 @@
|
|||
<string name="chosen_by">Chosen by %1$s</string>
|
||||
<string name="user">User</string>
|
||||
<string name="series">Series</string>
|
||||
<string name="load_from_server_plugin">Load default from server</string>
|
||||
<string name="save_to_local">Save only to this device</string>
|
||||
<string name="home_settings_source_unset">Default</string>
|
||||
<string name="home_settings_source_local">Device specific</string>
|
||||
<string name="home_settings_source_server_profile">Server user profile</string>
|
||||
<string name="home_settings_source_plugin">Server default</string>
|
||||
<string name="home_settings_source_web">User web client</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.data.model.KeyValueEntry
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
|
|
@ -13,7 +15,6 @@ import org.jellyfin.sdk.model.UUID
|
|||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import kotlin.reflect.KClass
|
||||
|
|
@ -61,8 +62,8 @@ class TestHomeRowSamples {
|
|||
),
|
||||
HomeRowConfig.GetItems(
|
||||
name = "Episodes by date created",
|
||||
getItems =
|
||||
GetItemsRequest(
|
||||
filter =
|
||||
HomeGetItemsFilter(
|
||||
parentId = UUID.randomUUID(),
|
||||
recursive = true,
|
||||
isFavorite = true,
|
||||
|
|
@ -77,6 +78,12 @@ class TestHomeRowSamples {
|
|||
HomeRowConfig.TvPrograms(),
|
||||
HomeRowConfig.TvChannels(),
|
||||
HomeRowConfig.Suggestions(parentId = UUID.randomUUID()),
|
||||
HomeRowConfig.CustomEndpoint(
|
||||
endpoint = "/path/to/items",
|
||||
title = "Items",
|
||||
query = listOf(KeyValueEntry("key", "value")),
|
||||
headers = listOf(KeyValueEntry("X-Header", "1234")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +107,7 @@ class TestHomeRowSamples {
|
|||
is HomeRowConfig.Suggestions -> foundTypes.add(it::class)
|
||||
is HomeRowConfig.TvChannels -> foundTypes.add(it::class)
|
||||
is HomeRowConfig.Studios -> foundTypes.add(it::class)
|
||||
is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class)
|
||||
}
|
||||
}
|
||||
Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)
|
||||
|
|
@ -126,12 +134,10 @@ class TestHomeRowSamples {
|
|||
context = mockk(),
|
||||
api = mockk(),
|
||||
serverRepository = mockk(),
|
||||
userPreferencesService = mockk(),
|
||||
navDrawerService = mockk(),
|
||||
latestNextUpService = mockk(),
|
||||
imageUrlService = mockk(),
|
||||
suggestionService = mockk(),
|
||||
displayPreferencesService = mockk(),
|
||||
serverPluginApi = mockk(),
|
||||
serverDao = mockk(),
|
||||
)
|
||||
|
||||
val str = """{
|
||||
|
|
@ -149,7 +155,7 @@ class TestHomeRowSamples {
|
|||
]
|
||||
}"""
|
||||
|
||||
val jsonElement = service.jsonParser.parseToJsonElement(str)
|
||||
val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str)
|
||||
val settings = service.decode(jsonElement)
|
||||
Assert.assertEquals(1, settings.rows.size)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue