Persist audio & subtitle track changes for series if in different language (#405)

This only applies if the user has set a preferred audio and/or subtitle
language on the web UI!

If the user chooses an audio/subtitle track that is a different language
than their preferred, it will be remembered for that series. Playback of
future episodes of the series will pick a matching language.

The audio & subtitle selections are independent. You can make the choice
before playback or during playback.

Closes #378
This commit is contained in:
damontecres 2025-12-11 10:16:45 -05:00 committed by GitHub
parent 033bdccaa2
commit 0f4254c6ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 990 additions and 324 deletions

View file

@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy
@ -22,8 +23,15 @@ import timber.log.Timber
import java.util.UUID
@Database(
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
version = 10,
entities = [
JellyfinServer::class,
JellyfinUser::class,
ItemPlayback::class,
NavDrawerPinnedItem::class,
LibraryDisplayInfo::class,
PlaybackLanguageChoice::class,
],
version = 11,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -33,6 +41,7 @@ import java.util.UUID
AutoMigration(7, 8),
AutoMigration(8, 9),
AutoMigration(9, 10),
AutoMigration(10, 11),
],
)
@TypeConverters(Converters::class)
@ -44,6 +53,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun serverPreferencesDao(): ServerPreferencesDao
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
}
class Converters {

View file

@ -2,10 +2,13 @@ package com.github.damontecres.wholphin.data
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -20,47 +23,59 @@ class ItemPlaybackRepository
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
private val streamChoiceService: StreamChoiceService,
) {
fun getSelectedTracks(
suspend fun getSelectedTracks(
itemId: UUID,
item: BaseItem,
prefs: UserPreferences,
): ChosenStreams? =
serverRepository.currentUser.value?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) {
Timber.v("Got itemPlayback for %s", itemId)
getChosenItemFromPlayback(item, itemPlayback)
} else {
null
}
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
}
fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback,
itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
): ChosenStreams? {
val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
if (source != null) {
val audioStream =
if (itemPlayback.audioIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
null
}
streamChoiceService.chooseAudioStream(
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
.orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
prefs = prefs,
)
val subtitleStream =
if (itemPlayback.subtitleIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
null
}
streamChoiceService.chooseSubtitleStream(
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty(),
itemPlayback = itemPlayback,
playbackLanguageChoice = plc,
prefs = prefs,
)
return ChosenStreams(
itemPlayback,
item.id,
source.id?.toUUIDOrNull(),
audioStream,
subtitleStream,
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
itemPlayback = itemPlayback,
plc = plc,
itemId = item.id,
source = source,
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
audioStream = audioStream,
subtitleStream = subtitleStream,
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
)
} else {
return null
@ -89,22 +104,61 @@ class ItemPlaybackRepository
itemPlayback: ItemPlayback?,
trackIndex: Int,
type: MediaStreamType,
) = serverRepository.currentUser.value?.let { user ->
var toSave =
itemPlayback ?: ItemPlayback(
userId = user.rowId,
itemId = item.id,
sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(),
)
toSave =
when (type) {
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
else -> toSave
): ItemPlayback =
serverRepository.current.value!!.let { current ->
val source =
itemPlayback?.sourceId?.let { sourceId ->
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == sourceId }
} ?: streamChoiceService.chooseSource(item.data, null)
if (source == null) {
Timber.w("Could not find media source for ${item.id}")
throw IllegalArgumentException("Could not find media source for ${item.id}")
}
Timber.v("Saving track selection %s", toSave)
saveItemPlayback(toSave)
}
var toSave =
itemPlayback ?: ItemPlayback(
userId = current.user.rowId,
itemId = item.id,
sourceId = source.id?.toUUIDOrNull(),
)
toSave =
when (type) {
MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex)
MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex)
else -> toSave
}
Timber.v("Saving track selection %s", toSave)
toSave = saveItemPlayback(toSave)
val seriesId = item.data.seriesId
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
if (type == MediaStreamType.AUDIO) {
val audioLang = current.userDto.configuration?.audioLanguagePreference
val stream = source.mediaStreams?.first { it.index == trackIndex }
if (stream?.language != null && stream.language != audioLang) {
streamChoiceService.updateAudio(item.data, stream.language!!)
}
} else if (type == MediaStreamType.SUBTITLE) {
if (trackIndex == TrackIndex.DISABLED) {
streamChoiceService.updateSubtitles(
item.data,
subtitleLang = null,
subtitlesDisabled = true,
)
} else {
val subtitleLang =
current.userDto.configuration?.subtitleLanguagePreference
val stream = source.mediaStreams?.first { it.index == trackIndex }
if (stream?.language != null && stream.language != subtitleLang) {
streamChoiceService.updateSubtitles(
item.data,
stream.language!!,
subtitlesDisabled = false,
)
}
}
}
}
toSave
}
/**
* Saves the [ItemPlayback] into the database, returning the same object with the rowId updated if needed
@ -127,9 +181,11 @@ class ItemPlaybackRepository
}
data class ChosenStreams(
val itemPlayback: ItemPlayback,
val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?,
val itemId: UUID,
val sourceId: UUID?,
val source: MediaSourceInfo,
val videoStream: MediaStream?,
val audioStream: MediaStream?,
val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean,

View file

@ -0,0 +1,20 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import java.util.UUID
@Dao
interface PlaybackLanguageChoiceDao {
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
suspend fun get(
userId: Int,
seriesId: UUID,
): PlaybackLanguageChoice?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long
}

View file

@ -7,19 +7,10 @@ import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
@Entity(
@ -53,122 +44,6 @@ data class ItemPlayback(
val subtitleIndexEnabled = subtitleIndex >= 0
}
/**
* Returns the [MediaSourceInfo] with the highest video resolution
*/
fun chooseSource(sources: List<MediaSourceInfo>?) =
sources?.letNotEmpty { sources ->
val result =
sources.maxByOrNull { s ->
s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video ->
(video.width ?: 0) * (video.height ?: 0)
} ?: 0
}
result
}
/**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
*/
fun chooseSource(
dto: BaseItemDto,
itemPlayback: ItemPlayback?,
) = itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } }
?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull()
fun chooseStream(
dto: BaseItemDto,
itemPlayback: ItemPlayback?,
type: MediaStreamType,
prefs: UserPreferences,
): MediaStream? {
val source = chooseSource(dto, itemPlayback)
return source?.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == type }
when (type) {
MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, prefs)
MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, prefs)
else -> candidates.firstOrNull()
}
}
}
fun chooseAudioStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
prefs: UserPreferences,
): MediaStream? =
if (itemPlayback?.audioIndexEnabled == true) {
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
// TODO audio selection based on channel layout or preferences or default
val audioLanguage = prefs.userConfig.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank()) {
val sorted =
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
?: sorted.firstOrNull { it.language == audioLanguage }
?: sorted.firstOrNull { it.isDefault }
?: sorted.firstOrNull()
} else {
candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull()
}
}
fun chooseSubtitleStream(
candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
prefs: UserPreferences,
): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null
} else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
val subtitleLanguage = prefs.userConfig.subtitleLanguagePreference
return when (prefs.userConfig.subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage }
} else {
candidates.firstOrNull()
}
}
SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage != null) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.SMART -> {
val audioLanguage = prefs.userConfig.audioLanguagePreference
if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
candidates.firstOrNull { it.language == subtitleLanguage }
} else {
null
}
}
SubtitlePlaybackMode.DEFAULT -> {
// TODO check for language?
(
candidates.firstOrNull { it.isDefault && it.isForced }
?: candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull { it.isForced }
)
}
SubtitlePlaybackMode.NONE -> {
null
}
}
}
}
object TrackIndex {
/**
* The user has not explicitly specified a track to use

View file

@ -0,0 +1,32 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
primaryKeys = ["userId", "seriesId"],
)
@Serializable
data class PlaybackLanguageChoice(
val userId: Int,
val seriesId: UUID,
val itemId: UUID? = null,
val audioLanguage: String? = null,
val subtitleLanguage: String? = null,
val subtitlesDisabled: Boolean? = null,
)