mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
Rebrand
This commit is contained in:
parent
aabd8462dc
commit
d39ed721d5
138 changed files with 738 additions and 738 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() } }
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue