This commit is contained in:
Damontecres 2025-10-15 15:27:30 -04:00
parent aabd8462dc
commit d39ed721d5
No known key found for this signature in database
138 changed files with 738 additions and 738 deletions

View file

@ -0,0 +1,13 @@
package com.github.damontecres.wholphin.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [JellyfinServer::class, JellyfinUser::class],
version = 2,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun serverDao(): JellyfinServerDao
}

View file

@ -0,0 +1,88 @@
package com.github.damontecres.wholphin.data
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Relation
import androidx.room.Transaction
import androidx.room.Update
@Entity(tableName = "servers")
data class JellyfinServer(
@PrimaryKey val id: String,
val name: String?,
val url: String,
)
@Entity(
tableName = "users",
primaryKeys = ["id", "serverId"],
foreignKeys = [
ForeignKey(
entity = JellyfinServer::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("serverId"),
onDelete = ForeignKey.CASCADE,
),
],
)
data class JellyfinUser(
@ColumnInfo(index = true)
val id: String,
val name: String?,
@ColumnInfo(index = true)
val serverId: String,
val accessToken: String?,
)
data class JellyfinServerUsers(
@Embedded val server: JellyfinServer,
@Relation(
parentColumn = "id",
entityColumn = "serverId",
)
val users: List<JellyfinUser>,
)
@Dao
interface JellyfinServerDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addServer(server: JellyfinServer): Long
@Update
fun updateServer(server: JellyfinServer): Int
@Transaction
fun addOrUpdateServer(server: JellyfinServer) {
val result = addServer(server)
if (result == -1L) {
updateServer(server)
}
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addUser(user: JellyfinUser)
@Query("DELETE FROM servers WHERE id = :serverId")
fun deleteServer(serverId: String)
@Query("DELETE FROM users WHERE serverId = :serverId AND id = :userId")
fun deleteUser(
serverId: String,
userId: String,
)
@Transaction
@Query("SELECT * FROM servers")
fun getServers(): List<JellyfinServerUsers>
@Transaction
@Query("SELECT * FROM servers WHERE id = :serverId")
fun getServer(serverId: String): JellyfinServerUsers?
}

View file

@ -0,0 +1,192 @@
package com.github.damontecres.wholphin.data
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.preferences.AppPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Handles managing the current server & user as well as adding & removing new ones
*/
@Singleton
class ServerRepository
@Inject
constructor(
val jellyfin: Jellyfin,
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>,
) {
private var _currentServer by mutableStateOf<JellyfinServer?>(null)
val currentServer get() = _currentServer
private var _currentUser by mutableStateOf<JellyfinUser?>(null)
val currentUser get() = _currentUser
private var _currentUserDto by mutableStateOf<UserDto?>(null)
val currentUserDto get() = _currentUserDto
/**
* Adds a server to the app database and updated the [ApiClient] to the server's URL
*
* The current user is removed
*/
suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) {
serverDao.addOrUpdateServer(server)
}
changeServer(server)
}
/**
* Updates the [ApiClient] to the server's URL
*
* The current user is removed
*/
fun changeServer(server: JellyfinServer) {
apiClient.update(baseUrl = server.url, accessToken = null)
_currentServer = server
_currentUser = null
}
/**
* Saves the server & User to the app database and updates the [ApiClient] to use this server & user
*/
suspend fun changeUser(
server: JellyfinServer,
user: JellyfinUser,
) = withContext(Dispatchers.IO) {
if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server")
}
Timber.v("Changing user to ${user.name} on ${server.url}")
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
val userDto =
apiClient.userApi
.getCurrentUser()
.content
val sysInfo by apiClient.systemApi.getSystemInfo()
val updatedServer = server.copy(name = sysInfo.serverName)
val updatedUser =
user.copy(
id = userDto.id.toString(),
name = userDto.name,
)
serverDao.addOrUpdateServer(updatedServer)
serverDao.addUser(updatedUser)
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = updatedServer.id
currentUserId = updatedUser.id
}.build()
}
withContext(Dispatchers.Main) {
_currentUserDto = userDto
_currentServer = updatedServer
_currentUser = updatedUser
}
}
/**
* Restores a session for the given server & user such as when the app reopens
*/
suspend fun restoreSession(
serverId: String,
userId: String,
): Boolean {
val serverAndUsers =
withContext(Dispatchers.IO) {
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
_currentServer = serverAndUsers.server
val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null) {
changeUser(serverAndUsers.server, user)
return true
}
}
return false
}
/**
* Given a successful [AuthenticationResult], switch to the user that just authenticated
*/
suspend fun changeUser(
serverUrl: String,
authenticationResult: AuthenticationResult,
) = withContext(Dispatchers.IO) {
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
val authedUser = authenticationResult.user
val server =
authenticationResult.serverId?.let {
JellyfinServer(
id = it,
name = authedUser?.serverName,
url = serverUrl,
)
}
if (server != null) {
val user =
authedUser?.let {
JellyfinUser(
id = it.id.toString(),
name = it.name,
serverId = server.id,
accessToken = accessToken,
)
}
if (user != null) {
changeUser(server, user)
}
}
}
}
suspend fun removeUser(user: JellyfinUser) {
if (currentUser == user) {
_currentUser = null
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentUserId = ""
}.build()
}
apiClient.update(accessToken = null)
}
withContext(Dispatchers.IO) {
serverDao.deleteUser(user.serverId, user.id)
}
}
suspend fun removeServer(server: JellyfinServer) {
if (currentServer == server) {
_currentServer = null
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = ""
}.build()
}
}
withContext(Dispatchers.IO) {
serverDao.deleteServer(server.id)
}
}
}

View file

@ -0,0 +1,124 @@
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
@Serializable
data class BaseItem(
val data: BaseItemDto,
val imageUrl: String?,
val backdropImageUrl: String? = null,
) {
@Transient val id = data.id
@Transient val type = data.type
@Transient val name = data.name
@Transient
val title = if (type == BaseItemKind.EPISODE) data.seriesName else name
@Transient
val subtitle =
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
@Transient
val resumeMs =
data.userData
?.playbackPositionTicks
?.ticks
?.inWholeMilliseconds
@Transient
val indexNumber = data.indexNumber ?: dateAsIndex()
private fun dateAsIndex(): Int? =
data.premiereDate
?.let {
it.year.toString() +
it.monthValue.toString().padStart(2, '0') +
it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull()
fun destination(): Destination {
val result =
// Redirect episodes & seasons to their series if possible
when (type) {
BaseItemKind.EPISODE -> {
indexNumber?.let { episode ->
data.parentIndexNumber?.let { season ->
Destination.SeriesOverview(
data.seriesId!!,
BaseItemKind.SERIES,
this,
SeasonEpisode(season, episode),
)
}
} ?: Destination.MediaItem(id, type, this)
}
BaseItemKind.SEASON ->
data.indexNumber?.let { season ->
Destination.SeriesOverview(
data.seriesId!!,
BaseItemKind.SERIES,
this,
SeasonEpisode(season, 0),
)
} ?: Destination.MediaItem(id, type, this)
else -> Destination.MediaItem(id, type, this)
}
return result
}
companion object {
fun from(
dto: BaseItemDto,
api: ApiClient,
useSeriesForPrimary: Boolean = false,
): BaseItem {
val backdropImageUrl =
if (dto.type == BaseItemKind.EPISODE) {
val seriesId = dto.seriesId
if (seriesId != null) {
api.imageApi.getItemImageUrl(seriesId, ImageType.BACKDROP)
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP)
}
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP)
}
val primaryImageUrl =
if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE) {
val seriesId = dto.seriesId
if (seriesId != null) {
api.imageApi.getItemImageUrl(seriesId, ImageType.PRIMARY)
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)
}
} else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) {
// TODO is this a bad assumption?
null
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)
}
return BaseItem(
dto,
primaryImageUrl,
backdropImageUrl,
)
}
}
}
val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }

View file

@ -0,0 +1,36 @@
package com.github.damontecres.wholphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
data class Chapter(
val name: String?,
val position: Duration,
val imageUrl: String?,
) {
companion object {
fun fromDto(
dto: BaseItemDto,
api: ApiClient,
): List<Chapter> =
dto.chapters
?.mapIndexed { index, chapter ->
Chapter(
chapter.name,
chapter.startPositionTicks.ticks,
chapter.imageTag?.let {
api.imageApi.getItemImageUrl(
itemId = dto.id,
imageType = ImageType.CHAPTER,
tag = it,
imageIndex = index,
)
},
)
}.orEmpty()
}
}

View file

@ -0,0 +1,30 @@
package com.github.damontecres.wholphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.PersonKind
data class Person(
val id: UUID,
val name: String?,
val role: String?,
val type: PersonKind,
val imageUrl: String?,
) {
companion object {
fun fromDto(
dto: BaseItemPerson,
api: ApiClient,
): Person =
Person(
id = dto.id,
name = dto.name,
role = dto.role,
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
)
}
}

View file

@ -0,0 +1,95 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
class Playlist(
items: List<BaseItem>,
startIndex: Int = 0,
) {
val items = items.subList(startIndex, items.size)
var index by mutableIntStateOf(0)
fun hasPrevious(): Boolean = index > 0
fun hasNext(): Boolean = index < items.size
fun getPreviousAndReverse(): BaseItem = items[--index]
fun getAndAdvance(): BaseItem = items[++index]
fun peek(): BaseItem? = items.getOrNull(index + 1)
fun upcomingItems(): List<BaseItem> = items.subList(index + 1, items.size)
fun advanceTo(id: UUID): BaseItem? {
while (hasNext()) {
val potential = getAndAdvance()
if (potential.id == id) {
return potential
}
}
return null
}
companion object {
const val MAX_SIZE = 100
}
}
@Singleton
class PlaylistCreator
@Inject
constructor(
private val api: ApiClient,
) {
suspend fun createFromEpisode(
seriesId: UUID,
episodeId: UUID,
): Playlist {
val request =
GetEpisodesRequest(
seriesId = seriesId,
fields = DefaultItemFields,
startItemId = episodeId,
limit = Playlist.MAX_SIZE,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val startIndex =
episodes.indexOfFirstOrNull { it.id == episodeId }
?: throw IllegalStateException("Episode $episodeId was not returned")
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
}
suspend fun createFromPlaylistId(
playlistId: UUID,
startIndex: Int?,
shuffled: Boolean,
): Playlist {
val request =
GetPlaylistItemsRequest(
playlistId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
)
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.from(it, api) }
if (shuffled) {
baseItems = baseItems.shuffled()
}
return Playlist(baseItems, 0)
}
}