diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json new file mode 100644 index 00000000..4a41a052 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json @@ -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')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index d390a4b4..45af2d65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 329b6786..2c24c8f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -72,6 +72,8 @@ class ServerRepository } } + val serverPluginInstalled = MutableStateFlow(false) + /** * Adds a server to the app database and updated the [ApiClient] to the server's URL * diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 4914248f..39b82bc8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -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. + * 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? = null, + val query: List? = 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? = null, + val sortOrder: List? = null, + val ids: List? = null, + val minCommunityRating: Double? = null, + val minCriticRating: Double? = null, + val recursive: Boolean? = null, + val searchTerm: String? = null, + val parentId: UUID? = null, + val includeItemTypes: List? = null, + val isFavorite: Boolean? = null, + val isPlayed: Boolean? = null, + val officialRatings: Collection? = null, + val tags: List? = null, + val years: List? = null, + val personIds: List? = null, + val minOfficialRating: String? = null, + val hasOfficialRating: Boolean? = null, + val studioIds: List? = null, + val genreIds: List? = 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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index 4c54311c..6c5a2a58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -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] */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt new file mode 100644 index 00000000..fddd4be7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt @@ -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, + 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 { + 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(it.body.byteStream()) + Timber.v("Got %s items from custom endpoint", queryResult.items.size) + queryResult.items + } + } + } + +private val Library?.itemFields: List + 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? + get() = + collectionType.let { collectionType -> + getTypeFor(collectionType)?.let { type -> listOf(type) } + } + +private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 7145f690..1c05640e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -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 { + 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, - 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, ) { + 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 - 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), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt new file mode 100644 index 00000000..896b483c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt @@ -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(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()) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index 06115982..b64b15ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index 1fd7ea9f..4e49bd39 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 847e2e69..d02ba907 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt index ad433c23..ae958a93 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt @@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey { @Serializable data object GlobalSettings : HomeSettingsDestination + @Serializable + data object SourceSettings : HomeSettingsDestination + @Serializable data object Presets : HomeSettingsDestination } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt index 894980c4..74c546b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -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, - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 5a2d6c78..0bd6b7db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -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(null) } var searchForDialog by remember { mutableStateOf(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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index dc527ed4..72289463 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -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 { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt new file mode 100644 index 00000000..8139b651 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index e3eafcbb..1082a23f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -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, - @param:IoCoroutineScope private val ioScope: CoroutineScope, + val navigationManager: NavigationManager, ) : ViewModel() { private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) val state: StateFlow = _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() 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, val rowData: List, val libraries: List, @@ -873,6 +948,7 @@ data class HomePageSettingsState( val EMPTY = HomePageSettingsState( LoadingState.Pending, + HomePageSettingsSource.UNSET, emptyList(), emptyList(), emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt new file mode 100644 index 00000000..c7531b6d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt @@ -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, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46bf080..974b094a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -967,4 +967,11 @@ Chosen by %1$s User Series + Load default from server + Save only to this device + Default + Device specific + Server user profile + Server default + User web client diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index e8c2fed1..a6fe6e2a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -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) }