Add support for server plugin (#1384)

## Description
PR to add support for a Wholphin server plugin:
https://github.com/damontecres/jellyfin-plugin-wholphin

Note: The plugin is not required to use Wholphin. It adds features the
server admin can use to set default settings like the home page or
eventually Seerr connection info.

### Changes
Wholphin will use the home page settings in order of:
1. Saved on the device locally
2. Saved on the server in the user's profile settings (display
preferences)
3. Provided by `jellyfin-plugin-wholphin`
4. Default fallback: combined continue watching & recently added in
libraries

Under customize home page, you can now see the "source" of the home
settings. This also allows to pick a different source

Finally, instead of the home customization being saved in the background
only locally, if there are any changes, a dialog is shown with options
to save locally, server-side user profile, or discard.

So overall, it's more clear where the home page settings are from and
where the user wants to save them.

### Install

[See instructions
here](https://github.com/damontecres/Wholphin/releases/tag/develop-server-plugin)

### Related issues
Related to #598

### Testing
WIP

## Screenshots
N/A

## AI or LLM usage
None

---------

Co-authored-by: Mario Reisinger <marioreisinger95@gmail.com>
This commit is contained in:
Damontecres 2026-10-19 14:41:08 -04:00 • committed by GitHub
parent e273195e93
commit 551501fa75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2331 additions and 858 deletions

View file

@ -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')"
]
}
}

View file

@ -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)

View file

@ -72,6 +72,8 @@ class ServerRepository
}
}
val serverPluginInstalled = MutableStateFlow<Boolean>(false)
/**
* Adds a server to the app database and updated the [ApiClient] to the server's URL
*

View file

@ -1,4 +1,7 @@
@file:UseSerializers(UUIDSerializer::class)
@file:UseSerializers(
UUIDSerializer::class,
DateTimeSerializer::class,
)
package com.github.damontecres.wholphin.data.model
@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.DateTimeSerializer
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@ -174,19 +181,45 @@ sealed interface HomeRowConfig {
}
/**
* An arbitrary [GetItemsRequest] allowing to query for anything
* An arbitrary [HomeGetItemsFilter] allowing to query for most anything
*/
@Serializable
@SerialName("GetItems")
data class GetItems(
val name: String,
val getItems: GetItemsRequest,
val filter: HomeGetItemsFilter,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
}
/**
* Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult<BaseItemDto>.
* Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing
* specific knowledge of them.
*
* Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin
* config is persisted as XML, which doesn't support IDictionary.
*/
@Serializable
@SerialName("CustomEndpoint")
data class CustomEndpoint(
val endpoint: String,
val title: String,
val headers: List<KeyValueEntry>? = null,
val query: List<KeyValueEntry>? = null,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions)
}
}
@Serializable
data class KeyValueEntry(
val key: String,
val value: String,
)
/**
* Root class for home page settings
*
@ -241,3 +274,54 @@ data class HomeRowViewOptions(
)
}
}
/**
* Subset of [GetItemsFilter]
*/
@Serializable
data class HomeGetItemsFilter(
val sortBy: List<ItemSortBy>? = null,
val sortOrder: List<SortOrder>? = null,
val ids: List<UUID>? = null,
val minCommunityRating: Double? = null,
val minCriticRating: Double? = null,
val recursive: Boolean? = null,
val searchTerm: String? = null,
val parentId: UUID? = null,
val includeItemTypes: List<BaseItemKind>? = null,
val isFavorite: Boolean? = null,
val isPlayed: Boolean? = null,
val officialRatings: Collection<String>? = null,
val tags: List<String>? = null,
val years: List<Int>? = null,
val personIds: List<UUID>? = null,
val minOfficialRating: String? = null,
val hasOfficialRating: Boolean? = null,
val studioIds: List<UUID>? = null,
val genreIds: List<UUID>? = null,
val minPremiereDate: LocalDateTime? = null,
val maxPremiereDate: LocalDateTime? = null,
) {
fun asGetItemsRequest() =
GetItemsRequest(
sortBy = sortBy,
sortOrder = sortOrder,
ids = ids,
minCommunityRating = minCommunityRating,
minCriticRating = minCriticRating,
recursive = recursive,
searchTerm = searchTerm,
parentId = parentId,
includeItemTypes = includeItemTypes,
isFavorite = isFavorite,
isPlayed = isPlayed,
officialRatings = officialRatings,
tags = tags,
years = years,
personIds = personIds,
minOfficialRating = minOfficialRating,
hasOfficialRating = hasOfficialRating,
studioIds = studioIds,
genreIds = genreIds,
)
}

View file

@ -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]
*/

View file

@ -0,0 +1,764 @@
package com.github.damontecres.wholphin.services
import androidx.core.net.toUri
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.HomeItemFields
import com.github.damontecres.wholphin.ui.ProgramItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.playback.getTypeFor
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.decodeFromStream
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber
import java.time.LocalDateTime
import javax.inject.Inject
import javax.inject.Singleton
@OptIn(ExperimentalSerializationApi::class)
@Singleton
class HomeDataService
@Inject
constructor(
private val api: ApiClient,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
private val serverRepository: ServerRepository,
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
private val suggestionService: SuggestionService,
) {
/**
* Fetch the data from the server for a given [HomeRowConfig]
*/
suspend fun fetchDataForRow(
row: HomeRowConfig,
scope: CoroutineScope,
prefs: HomePagePreferences,
userDto: ServerUserConfig,
libraries: List<Library>,
limit: Int = prefs.maxItemsPerRow,
isRefresh: Boolean,
usePaging: Boolean = false,
): HomeRowLoadingState =
when (row) {
is HomeRowConfig.ContinueWatching -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = resume.size >= limit,
)
}
is HomeRowConfig.NextUp -> {
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = nextUp.size >= limit,
)
}
is HomeRowConfig.ContinueWatchingCombined -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
val combined = latestNextUpService.buildCombined(resume, nextUp)
Success(
title = ResStringProvider(R.string.continue_watching),
items = combined.take(limit),
viewOptions = row.viewOptions,
rowType = row,
showViewMore = combined.size >= limit,
)
}
is HomeRowConfig.Genres -> {
val request =
GetGenresRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
)
val items =
GetGenresRequestHandler
.execute(api, request)
.content.items
val genreIds = items.map { it.id }
val genreImages =
getGenreImageMap(
api = api,
userId = serverRepository.currentUser?.id,
scope = scope,
imageUrlService = imageUrlService,
genres = genreIds,
parentId = row.parentId,
includeItemTypes = null,
cardWidthPx = null,
useCache = isRefresh,
)
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
?: ResStringProvider(R.string.genres)
val genres =
items.map {
BaseItem(
it,
false,
genreImages[it.id],
createGenreDestination(
genreId = it.id,
genreName = it.name ?: "",
parentId = row.parentId,
parentName = library?.name,
includeItemTypes = library?.includeItemTypes,
collectionType =
library?.collectionType
?: CollectionType.UNKNOWN,
),
)
}
Success(
title,
genres,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = genres.size >= limit,
)
}
is HomeRowConfig.Studios -> {
val request =
GetStudiosRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
includeItemTypes = listOf(BaseItemKind.SERIES),
)
val items =
GetStudiosRequestHandler
.execute(api, request)
.content.items
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
?: ResStringProvider(R.string.studios)
val studios =
items.map {
val imageUrl =
imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.THUMB,
tag = it.imageTags?.get(ImageType.THUMB),
)
BaseItem(
it,
false,
imageUrl,
createStudioDestination(
studioId = it.id,
name = it.name ?: "",
parentId = row.parentId,
parentName = library?.name,
includeItemTypes = library?.includeItemTypes,
),
)
}
Success(
title,
studios,
viewOptions = row.viewOptions,
showViewMore = studios.size >= limit,
)
}
is HomeRowConfig.RecentlyAdded -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library?.name)
val request =
GetLatestMediaRequest(
fields = library.itemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
latest
}
is HomeRowConfig.RecentlyReleased -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let {
ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released)
val request =
GetItemsRequest(
parentId = row.parentId,
limit = limit,
sortBy =
listOf(
ItemSortBy.PREMIERE_DATE,
ItemSortBy.SERIES_SORT_NAME,
ItemSortBy.AIRED_EPISODE_ORDER,
),
sortOrder =
listOf(
SortOrder.DESCENDING,
SortOrder.ASCENDING,
SortOrder.DESCENDING,
),
fields = library.itemFields,
recursive = true,
maxPremiereDate = LocalDateTime.now(),
isUnaired = false,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.ByParent -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val request =
GetItemsRequest(
userId = userDto.id,
parentId = row.parentId,
recursive = row.recursive,
sortBy =
row.sort?.let {
buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(ItemSortBy.SORT_NAME)
add(ItemSortBy.RANDOM)
} else {
add(it.sort)
if (it.sort != ItemSortBy.SORT_NAME) {
add(ItemSortBy.SORT_NAME)
}
}
}
},
sortOrder =
row.sort?.let {
buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(SortOrder.ASCENDING)
add(it.direction)
} else {
add(it.direction)
if (it.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
}
}
},
limit = limit,
fields = library.itemFields,
)
// Not using getItemName because we want to throw the 404
val title =
api.userLibraryApi
.getItem(
userId = serverRepository.currentUser?.id,
itemId = row.parentId,
).content.name
?.let { StringStringProvider(it) }
?: ResStringProvider(R.string.collection)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.GetItems -> {
val request =
row.filter.asGetItemsRequest().copy(
userId = userDto.id,
limit = limit,
fields = HomeItemFieldsBoxSets,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
StringStringProvider(row.name),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Favorite -> {
val title =
ResProviderStringProvider(
R.string.favorite_items_title,
ResStringProvider(formatTypeName(row.kind)),
)
val resultList =
when (row.kind) {
BaseItemKind.PERSON -> {
val request =
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
)
GetPersonsHandler.execute(api, request).toBaseItems()
}
BaseItemKind.MUSIC_ARTIST -> {
val request =
GetArtistsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetArtistsHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetArtistsHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
else -> {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = fields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
}
resultList.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Recordings -> {
val request =
GetRecordingsRequest(
userId = userDto.id,
isInProgress = true,
fields = HomeItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
enableTotalRecordCount = false,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetRecordingsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getRecordings(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
ResStringProvider(R.string.active_recordings),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.TvPrograms -> {
val request =
GetProgramsDto(
userId = userDto.id,
fields = ProgramItemFields,
limit = limit,
enableUserData = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1,
isAiring = true,
minEndDate = DateTime.now().plusMinutes(1),
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetProgramsDtoHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getPrograms(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
ResStringProvider(R.string.watch_live),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.TvChannels -> {
val request =
GetLiveTvChannelsRequest(
userId = userDto.id,
fields = HomeItemFields,
limit = limit,
enableImages = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetLiveTvChannelsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getLiveTvChannels(request)
.toBaseItems(api, row.viewOptions.useSeries)
}.let {
Success(
ResStringProvider(R.string.channels),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Suggestions -> {
val library =
api.userLibraryApi
.getItem(itemId = row.parentId)
.content
val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
if (itemKind != null) {
val suggestions =
suggestionService
.getSuggestionsFlow(row.parentId, itemKind)
.firstOrNull()
when (suggestions) {
SuggestionsResource.Empty -> {
Success(
title,
listOf(),
row.viewOptions,
rowType = row,
)
}
is SuggestionsResource.Success -> {
Success(
title,
suggestions.items,
row.viewOptions,
rowType = row,
showViewMore = suggestions.items.size >= limit,
)
}
SuggestionsResource.Loading,
null,
-> {
HomeRowLoadingState.Loading(title)
}
}
} else {
HomeRowLoadingState.Error(
title = title,
message = "Unsupported type ${library.collectionType}",
)
}
}
is HomeRowConfig.CustomEndpoint -> {
val title = StringStringProvider(row.title)
try {
val items =
fetchCustomEndpointItems(row)
.map { BaseItem(it, row.viewOptions.useSeries) }
Success(title, items, row.viewOptions, rowType = row)
} catch (ex: Exception) {
Timber.w(ex, "Custom endpoint %s failed", row.endpoint)
HomeRowLoadingState.Error(title, exception = ex)
}
}
}
private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List<BaseItemDto> {
val base =
api.baseUrl?.toHttpUrlOrNull()
?: throw IllegalStateException("Jellyfin baseUrl not set")
val endpointUri = row.endpoint.toUri()
if (!endpointUri.isRelative) {
throw IllegalArgumentException("Custom endpoint must be an path relative to Jellyfin baseUrl: ${row.endpoint}")
}
val params =
buildMap {
serverRepository.currentUser
?.id
?.toString()
?.let { put("userId", it) }
row.query?.forEach { put(it.key, it.value) }
}
val resolved =
base
.resolve(row.endpoint)
?.newBuilder()
?.apply {
params.forEach { (k, v) -> addQueryParameter(k, v) }
}?.build()
?: throw IllegalStateException("Could not resolve endpoint: ${row.endpoint}")
val request =
Request
.Builder()
.url(resolved)
.get()
.apply {
row.headers?.forEach { header(it.key, it.value) }
}.build()
val response =
authOkHttpClient
.newCall(request)
.execute()
return response.use {
if (!it.isSuccessful) {
throw InvalidStatusException(it.code, null)
}
val queryResult =
HomeSettingsService.jsonParser.decodeFromStream<BaseItemDtoQueryResult>(it.body.byteStream())
Timber.v("Got %s items from custom endpoint", queryResult.items.size)
queryResult.items
}
}
}
private val Library?.itemFields: List<ItemFields>
get() =
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
// Get child count to show in the header
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
private val Library.includeItemTypes: List<BaseItemKind>?
get() =
collectionType.let { collectionType ->
getTypeFor(collectionType)?.let { type -> listOf(type) }
}
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)

View file

@ -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 }
}
currentSettings.update { resolvedSettings }
/**
* Tries to load settings from local->server->plugin
*/
private suspend fun loadInOrder(userId: UUID): Pair<HomePageSettingsSource, HomePageSettings?> {
Timber.v("loadInOrder: local")
var settings =
HomePageSettingsSource.LOCAL to
tryLoad {
loadFromLocal(userId)
}
if (settings.second == null) {
Timber.v("loadInOrder: server")
settings =
HomePageSettingsSource.SERVER_PROFILE to
tryLoad {
loadFromServer(userId)
}
}
if (settings.second == null && serverRepository.serverPluginInstalled.value) {
Timber.v("loadInOrder: plugin")
settings = HomePageSettingsSource.PLUGIN to
tryLoad {
serverPluginApi.fetchHomePageSettings()
}
}
return settings
}
/**
* Resolve the settings and set them to be the current settings
*/
suspend fun updateCurrent(
userId: UUID,
source: HomePageSettingsSource,
settings: HomePageSettings,
) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
currentSettings.update { resolvedSettings }
currentSettings.update { it.copy(rows = resolvedRows) }
serverRepository.currentUser?.let { user ->
val toSave = user.updateConfig { it.copy(homeSettingsSource = source) }
serverDao.updateUser(toSave)
}
}
/**
@ -316,7 +318,7 @@ class HomeSettingsService
),
)
val rowConfig = continueWatchingRow + includedIds
return HomePageResolvedSettings(userId, rowConfig)
return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig)
}
/**
@ -432,7 +434,7 @@ class HomeSettingsService
null
}
}.flatten()
HomePageResolvedSettings(userId, rowConfigs)
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
} else {
null
}
@ -560,6 +562,14 @@ class HomeSettingsService
config,
)
}
is HomeRowConfig.CustomEndpoint -> {
HomeRowConfigDisplay(
id = id,
title = StringStringProvider(config.title),
config,
)
}
}
private suspend fun getItemName(
@ -585,632 +595,16 @@ class HomeSettingsService
ResStringProvider(R.string.unknown)
}
/**
* Fetch the data from the server for a given [HomeRowConfig]
*/
suspend fun fetchDataForRow(
row: HomeRowConfig,
scope: CoroutineScope,
prefs: HomePagePreferences,
userDto: ServerUserConfig,
libraries: List<Library>,
limit: Int = prefs.maxItemsPerRow,
isRefresh: Boolean,
usePaging: Boolean = false,
): HomeRowLoadingState =
when (row) {
is HomeRowConfig.ContinueWatching -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = resume.size >= limit,
)
}
is HomeRowConfig.NextUp -> {
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = nextUp.size >= limit,
)
}
is HomeRowConfig.ContinueWatchingCombined -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
val combined = latestNextUpService.buildCombined(resume, nextUp)
Success(
title = ResStringProvider(R.string.continue_watching),
items = combined.take(limit),
viewOptions = row.viewOptions,
rowType = row,
showViewMore = combined.size >= limit,
)
}
is HomeRowConfig.Genres -> {
val request =
GetGenresRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
)
val items =
GetGenresRequestHandler
.execute(api, request)
.content.items
val genreIds = items.map { it.id }
val genreImages =
getGenreImageMap(
api = api,
userId = serverRepository.currentUser?.id,
scope = scope,
imageUrlService = imageUrlService,
genres = genreIds,
parentId = row.parentId,
includeItemTypes = null,
cardWidthPx = null,
useCache = isRefresh,
)
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
?: ResStringProvider(R.string.genres)
val genres =
items.map {
BaseItem(
it,
false,
genreImages[it.id],
createGenreDestination(
genreId = it.id,
genreName = it.name ?: "",
parentId = row.parentId,
parentName = library?.name,
includeItemTypes =
library?.collectionType?.let {
getTypeFor(it)?.let {
listOf(it)
}
},
collectionType =
library?.collectionType
?: CollectionType.UNKNOWN,
),
)
}
Success(
title,
genres,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = genres.size >= limit,
)
}
is HomeRowConfig.Studios -> {
val request =
GetStudiosRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
includeItemTypes = listOf(BaseItemKind.SERIES),
)
val items =
GetStudiosRequestHandler
.execute(api, request)
.content.items
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
?: ResStringProvider(R.string.studios)
val studios =
items.map {
val imageUrl =
imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.THUMB,
tag = it.imageTags?.get(ImageType.THUMB),
)
BaseItem(
it,
false,
imageUrl,
createStudioDestination(
studioId = it.id,
name = it.name ?: "",
parentId = row.parentId,
parentName = library?.name,
includeItemTypes =
library?.collectionType?.let {
getTypeFor(it)?.let {
listOf(it)
}
},
),
)
}
Success(
title,
studios,
viewOptions = row.viewOptions,
showViewMore = studios.size >= limit,
)
}
is HomeRowConfig.RecentlyAdded -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library?.name)
val request =
GetLatestMediaRequest(
fields = library.itemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
latest
}
is HomeRowConfig.RecentlyReleased -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let {
ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released)
val request =
GetItemsRequest(
parentId = row.parentId,
limit = limit,
sortBy =
listOf(
ItemSortBy.PREMIERE_DATE,
ItemSortBy.SERIES_SORT_NAME,
ItemSortBy.AIRED_EPISODE_ORDER,
),
sortOrder =
listOf(
SortOrder.DESCENDING,
SortOrder.ASCENDING,
SortOrder.DESCENDING,
),
fields = library.itemFields,
recursive = true,
maxPremiereDate = LocalDateTime.now(),
isUnaired = false,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem.from(it, api, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.ByParent -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val request =
GetItemsRequest(
userId = userDto.id,
parentId = row.parentId,
recursive = row.recursive,
sortBy =
row.sort?.let {
buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(ItemSortBy.SORT_NAME)
add(ItemSortBy.RANDOM)
} else {
add(it.sort)
if (it.sort != ItemSortBy.SORT_NAME) {
add(ItemSortBy.SORT_NAME)
}
}
}
},
sortOrder =
row.sort?.let {
buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(SortOrder.ASCENDING)
add(it.direction)
} else {
add(it.direction)
if (it.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
}
}
},
limit = limit,
fields = library.itemFields,
)
// Not using getItemName because we want to throw the 404
val title =
api.userLibraryApi
.getItem(
userId = serverRepository.currentUser?.id,
itemId = row.parentId,
).content.name
?.let { StringStringProvider(it) }
?: ResStringProvider(R.string.collection)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.GetItems -> {
val request =
row.getItems.let {
if (it.limit == null) {
it.copy(
userId = userDto.id,
limit = limit,
)
} else {
it.copy(
userId = userDto.id,
)
}
}
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
StringStringProvider(row.name),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Favorite -> {
val title =
ResProviderStringProvider(
R.string.favorite_items_title,
ResStringProvider(formatTypeName(row.kind)),
)
val resultList =
when (row.kind) {
BaseItemKind.PERSON -> {
val request =
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
)
GetPersonsHandler.execute(api, request).toBaseItems()
}
BaseItemKind.MUSIC_ARTIST -> {
val request =
GetArtistsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetArtistsHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetArtistsHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
else -> {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = fields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
}
resultList.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Recordings -> {
val request =
GetRecordingsRequest(
userId = userDto.id,
isInProgress = true,
fields = HomeItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
enableTotalRecordCount = false,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetRecordingsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getRecordings(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
ResStringProvider(R.string.active_recordings),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.TvPrograms -> {
val request =
GetProgramsDto(
userId = userDto.id,
fields = ProgramItemFields,
limit = limit,
enableUserData = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1,
isAiring = true,
minEndDate = DateTime.now().plusMinutes(1),
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetProgramsDtoHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getPrograms(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
ResStringProvider(R.string.watch_live),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.TvChannels -> {
val request =
GetLiveTvChannelsRequest(
userId = userDto.id,
fields = HomeItemFields,
limit = limit,
enableImages = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetLiveTvChannelsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getLiveTvChannels(request)
.toBaseItems(api, row.viewOptions.useSeries)
}.let {
Success(
ResStringProvider(R.string.channels),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Suggestions -> {
val library =
api.userLibraryApi
.getItem(itemId = row.parentId)
.content
val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
if (itemKind != null) {
val suggestions =
suggestionService
.getSuggestionsFlow(row.parentId, itemKind)
.firstOrNull()
when (suggestions) {
SuggestionsResource.Empty -> {
Success(
title,
listOf(),
row.viewOptions,
rowType = row,
)
}
is SuggestionsResource.Success -> {
Success(
title,
suggestions.items,
row.viewOptions,
rowType = row,
showViewMore = suggestions.items.size >= limit,
)
}
SuggestionsResource.Loading,
null,
-> {
HomeRowLoadingState.Loading(title)
}
}
} else {
HomeRowLoadingState.Error(
title = title,
message = "Unsupported type ${library.collectionType}",
)
}
}
}
companion object {
const val CUSTOM_PREF_ID = "home_settings"
@OptIn(ExperimentalSerializationApi::class)
val jsonParser =
Json {
isLenient = true
ignoreUnknownKeys = true
allowTrailingComma = true
}
}
}
@ -1230,10 +624,14 @@ data class HomeRowConfigDisplay(
*/
data class HomePageResolvedSettings(
val userId: UUID,
val source: HomePageSettingsSource,
val rows: List<HomeRowConfigDisplay>,
) {
fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
companion object {
val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
val EMPTY =
HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList())
}
}
@ -1267,17 +665,6 @@ fun getRecentlyAddedTitle(name: String?): StringProvider =
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added)
private val Library?.itemFields: List<ItemFields>
get() =
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
// Get child count to show in the header
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
when (collectionType) {
CollectionType.MUSIC,
@ -1316,3 +703,14 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp
HomeRowViewOptions()
}
}
enum class HomePageSettingsSource(
@param:StringRes val stringResId: Int,
) {
UNSET(R.string.unknown),
LOCAL(R.string.home_settings_source_local),
SERVER_PROFILE(R.string.home_settings_source_server_profile),
PLUGIN(R.string.home_settings_source_plugin),
DEFAULT(R.string.home_settings_source_unset),
WEB_CONFIG(R.string.home_settings_source_web),
}

View file

@ -0,0 +1,88 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.decodeFromStream
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.IOException
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Service for interacting with the companion plugin
*/
@Singleton
class ServerPluginApi
@Inject
constructor(
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
private val api: ApiClient,
) {
private fun createUrl(path: String): String? =
api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" }
private val json get() = HomeSettingsService.jsonParser
companion object {
private const val HOME_CONFIG_PATH = "homesettings"
}
/**
* Check if the plugin is installed on the server
*/
suspend fun checkInstalled(): Boolean =
withContext(WholphinDispatchers.IO) {
val url = createUrl("public") ?: return@withContext false
val request =
Request
.Builder()
.url(url)
.get()
.build()
try {
return@withContext okHttpClient
.newCall(request)
.execute()
.use { it.isSuccessful }
} catch (ex: IOException) {
Timber.w("Could not connect to server plugin: %s", ex.message)
return@withContext false
}
}
/**
* Get the default home page settings from the plugin
*
* @return the home page settings or null if no rows are configured or the server isn't installed
*/
@OptIn(ExperimentalSerializationApi::class)
suspend fun fetchHomePageSettings(): HomePageSettings? =
withContext(WholphinDispatchers.IO) {
val url = createUrl(HOME_CONFIG_PATH) ?: return@withContext null
val request =
Request
.Builder()
.url(url)
.get()
.build()
return@withContext okHttpClient.newCall(request).execute().use { res ->
if (res.isSuccessful) {
json
.decodeFromStream<HomePageSettings>(res.body.byteStream())
.takeIf { it.rows.isNotEmpty() }
} else if (res.code == 404) {
Timber.w("fetchHomePageSettings returned 404")
null
} else {
throw ApiClientException(res.code.toString() + " " + res.body.string())
}
}
}
}

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey {
@Serializable
data object GlobalSettings : HomeSettingsDestination
@Serializable
data object SourceSettings : HomeSettingsDestination
@Serializable
data object Presets : HomeSettingsDestination
}

View file

@ -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,
)
}
}
}
}

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -64,11 +65,14 @@ fun HomeSettingsPage(
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
var showSaveDialog by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState()
val serverPluginActive by viewModel.serverPluginActive.collectAsState()
var position by rememberPosition(0, 0)
// TODO discover rows
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
val settingsHaveChanged by viewModel.settingsChanged.collectAsState()
// Adds a row, waits until its done loading, then scrolls to the new row
fun addRow(
@ -86,6 +90,10 @@ fun HomeSettingsPage(
}
}
BackHandler(settingsHaveChanged) {
showSaveDialog = true
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@ -137,6 +145,7 @@ fun HomeSettingsPage(
position = RowColumn(index, 0)
}
},
onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) },
modifier = destModifier,
)
}
@ -282,6 +291,16 @@ fun HomeSettingsPage(
}
},
onClickResize = { viewModel.resizeCards(it) },
onClickViewNextUp = {
showRemovedNextUpDialog = true
},
modifier = destModifier,
)
}
HomeSettingsDestination.SourceSettings -> {
HomeSettingsSourcePage(
source = state.source,
onClickSave = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_server_settings) {
@ -294,6 +313,13 @@ fun HomeSettingsPage(
viewModel.loadFromRemote()
}
},
serverPluginActive = serverPluginActive,
onClickLoadPlugin = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) {
viewModel.loadFromRemotePlugin()
}
},
onClickLoadWeb = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) {
@ -306,9 +332,6 @@ fun HomeSettingsPage(
addRow(false) { viewModel.resetToDefault() }
}
},
onClickViewNextUp = {
showRemovedNextUpDialog = true
},
modifier = destModifier,
)
}
@ -375,6 +398,29 @@ fun HomeSettingsPage(
)
}
}
if (showSaveDialog) {
SaveHomeSettingsDialog(
onDismissRequest = { showSaveDialog = false },
onSaveLocal = {
scope.launch {
viewModel.saveToLocal().join()
viewModel.navigationManager.goBack()
}
},
onSaveServer = {
scope.launch {
viewModel.saveToRemote().join()
viewModel.navigationManager.goBack()
}
},
onDiscard = {
scope.launch {
viewModel.discardChanges().join()
viewModel.navigationManager.goBack()
}
},
)
}
}
data class ShowConfirm(

View file

@ -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 {

View file

@ -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,
)
}
}
}
}

View file

@ -24,16 +24,18 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomePageSettingsSource
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.ServerPluginApi
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.services.viewOptionsForCollectionType
import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.launchDefault
@ -48,13 +50,16 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@ -76,37 +81,55 @@ class HomeSettingsViewModel
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val homeSettingsService: HomeSettingsService,
private val homeDataService: HomeDataService,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService,
private val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository,
private val serverPluginApi: ServerPluginApi,
val preferencesDataStore: DataStore<AppPreferences>,
@param:IoCoroutineScope private val ioScope: CoroutineScope,
val navigationManager: NavigationManager,
) : ViewModel() {
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
val state: StateFlow<HomePageSettingsState> = _state
val serverPluginActive get() = serverRepository.serverPluginInstalled
val currentUser
get() =
serverRepository.currentUserFlow.filterNotNull().stateIn(
viewModelScope,
SharingStarted.Eagerly,
serverRepository.currentUser!!,
)
val settingsChanged =
state
.map {
HomePageSettings(
rows = it.rows.map { it.config },
version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) != originalSettings
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private var idCounter by Delegates.notNull<Int>()
val discoverEnabled = seerrServerRepository.active
private var originalLocalSettings: HomePageSettings? = null
private var originalRemoteSettings: HomePageSettings? = null
private var originalSettings: HomePageSettings? = null
init {
addCloseable { saveToLocal() }
viewModelScope.launchIO {
val user = serverRepository.currentUser ?: return@launchIO
val userDto = serverRepository.currentUserDto ?: return@launchIO
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val currentSettings =
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
originalSettings = currentSettings.asHomePageSettings()
Timber.v("currentSettings=%s", currentSettings)
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(
source = currentSettings.source,
libraries = libraries,
rows = currentSettings.rows,
)
@ -134,7 +157,7 @@ class HomeSettingsViewModel
viewModelScope.async(WholphinDispatchers.IO) {
semaphore.withPermit {
try {
homeSettingsService.fetchDataForRow(
homeDataService.fetchDataForRow(
row = row.config,
scope = viewModelScope,
prefs = prefs,
@ -496,7 +519,7 @@ class HomeSettingsViewModel
}
}
fun saveToRemote() {
fun saveToRemote(): Job =
viewModelScope.launchIO {
serverRepository.currentUser?.let { user ->
Timber.d("Saving home settings to remote")
@ -506,6 +529,10 @@ class HomeSettingsViewModel
try {
Timber.d("saveToRemote")
homeSettingsService.saveToServer(user.id, settings)
homeSettingsService.updateCurrent(
HomePageSettingsSource.SERVER_PROFILE,
settings,
)
showSaveToast()
} catch (ex: Exception) {
Timber.e(ex)
@ -513,7 +540,6 @@ class HomeSettingsViewModel
}
}
}
}
fun loadFromRemote() {
viewModelScope.launchIO {
@ -530,7 +556,10 @@ class HomeSettingsViewModel
}
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(rows = newRows)
it.copy(
source = HomePageSettingsSource.SERVER_PROFILE,
rows = newRows,
)
}
} else {
Timber.v("No remote settings")
@ -560,7 +589,10 @@ class HomeSettingsViewModel
Timber.v("Got web settings")
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(rows = result.rows)
it.copy(
source = HomePageSettingsSource.LOCAL,
rows = result.rows,
)
}
} else {
Timber.v("No web settings")
@ -575,34 +607,19 @@ class HomeSettingsViewModel
}
}
fun saveToLocal() {
// This uses injected ioScope so that it will still run when the page is closing
ioScope.launchIO {
fun saveToLocal(): Job =
viewModelScope.launchIO {
serverRepository.currentUser?.let { user ->
val rows = state.value.rows.map { it.config }
val settings =
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
try {
Timber.d("saveToLocal")
// Only save if there are changes based on original source
val shouldSave =
if (originalLocalSettings != null) {
originalLocalSettings != settings
} else if (originalRemoteSettings != null) {
originalRemoteSettings != settings
} else {
true
}
if (shouldSave) {
homeSettingsService.saveToLocal(user.id, settings)
homeSettingsService.updateCurrent(user.id, settings)
showSaveToast()
} else {
Timber.d("No changes")
}
} catch (ex: UnsupportedHomeSettingsVersionException) {
Timber.w(ex, "Overwriting local settings")
homeSettingsService.saveToLocal(user.id, settings)
homeSettingsService.updateCurrent(
HomePageSettingsSource.LOCAL,
settings,
)
showSaveToast()
} catch (ex: Exception) {
Timber.e(ex)
@ -610,17 +627,11 @@ class HomeSettingsViewModel
}
}
}
}
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
_state.update {
update.invoke(it)
}
serverRepository.currentUser?.id?.let { userId ->
homeSettingsService.currentSettings.update {
HomePageResolvedSettings(userId, state.value.rows)
}
}
}
fun resizeCards(relative: Int) {
@ -649,12 +660,32 @@ class HomeSettingsViewModel
fun resetToDefault() =
viewModelScope.launchIO {
val userId = serverRepository.currentUser?.id ?: return@launchIO
val user = serverRepository.currentUser ?: return@launchIO
_state.update { it.copy(loading = LoadingState.Loading) }
val result = homeSettingsService.createDefault(userId)
val result =
if (serverPluginActive.value) {
try {
val settings =
homeSettingsService.fetchSettingsFor(
user.id,
HomePageSettingsSource.PLUGIN,
)
settings
} catch (ex: Exception) {
Timber.e(ex, "Plugin is active, but error fetching home settings")
null
}
} else {
null
} ?: homeSettingsService.createDefault(user.id)
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(rows = result.rows, rowData = emptyList())
it.copy(
source = result.source,
rows = result.rows,
rowData = emptyList(),
)
}
fetchRowData()
}
@ -746,6 +777,10 @@ class HomeSettingsViewModel
is HomeRowConfig.TvChannels -> {
it.config.updateViewOptions(preset.liveTv)
}
is HomeRowConfig.CustomEndpoint -> {
it.config
}
}
it.copy(config = newConfig)
}
@ -767,6 +802,43 @@ class HomeSettingsViewModel
}
}
fun loadFromRemotePlugin() {
viewModelScope.launchIO {
Timber.d("Loading home settings from server plugin")
try {
// TODO should this remove local settings?
// TODO how to set up priorities
_state.update { it.copy(loading = LoadingState.Loading) }
val result = serverPluginApi.fetchHomePageSettings()
if (result != null) {
Timber.v("Got remote settings")
val newRows =
result.rows.mapIndexed { index, config ->
homeSettingsService.resolve(index, config)
}
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(
source = HomePageSettingsSource.PLUGIN,
rows = newRows,
)
}
} else {
Timber.v("No server plugin settings")
showToast(context, "No server plugin settings found")
}
fetchRowData()
} catch (ex: UnsupportedHomeSettingsVersionException) {
// TODO
Timber.w(ex)
showToast(context, "Error: ${ex.localizedMessage}")
} catch (ex: Exception) {
Timber.e(ex)
showToast(context, "Error: ${ex.localizedMessage}")
}
}
}
fun onConfigAction(
row: HomeRowConfigDisplay,
action: HomeRowConfigAction,
@ -861,10 +933,13 @@ class HomeSettingsViewModel
fetchRowData()
}
}
fun discardChanges(): Job = Job().apply { complete() }
}
data class HomePageSettingsState(
val loading: LoadingState,
val source: HomePageSettingsSource,
val rows: List<HomeRowConfigDisplay>,
val rowData: List<HomeRowLoadingState>,
val libraries: List<Library>,
@ -873,6 +948,7 @@ data class HomePageSettingsState(
val EMPTY =
HomePageSettingsState(
LoadingState.Pending,
HomePageSettingsSource.UNSET,
emptyList(),
emptyList(),
emptyList(),

View file

@ -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,
)
}

View file

@ -967,4 +967,11 @@
<string name="chosen_by">Chosen by %1$s</string>
<string name="user">User</string>
<string name="series">Series</string>
<string name="load_from_server_plugin">Load default from server</string>
<string name="save_to_local">Save only to this device</string>
<string name="home_settings_source_unset">Default</string>
<string name="home_settings_source_local">Device specific</string>
<string name="home_settings_source_server_profile">Server user profile</string>
<string name="home_settings_source_plugin">Server default</string>
<string name="home_settings_source_web">User web client</string>
</resources>

View file

@ -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)
}