diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StrmFileHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StrmFileHandler.kt new file mode 100644 index 000000000..73c69e540 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StrmFileHandler.kt @@ -0,0 +1,96 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.BaseItem +import kotlinx.coroutines.delay +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.TimeoutException +import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.PlaybackInfoDto +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +/** + * Resolves .strm files to populate the tracks & metadata + */ +@Singleton +class StrmFileHandler + @Inject + constructor( + private val api: ApiClient, + ) { + /** + * Attempts to resolve if the strm file is not yet resolved. + * + * If the item is not a strm, file, nothing happens + * + * @return the resolved item or null if could not be resolved + */ + suspend fun resolveStrm( + item: BaseItem, + sourceId: String? = null, + ): BaseItem? { + if (shouldResolveStrm(item, sourceId)) { + Timber.d("Resolving strm %s", item.id) + resolve(item.id, null) + var count = 5 + while (count > 0) { + Timber.v("Checking for streams in %s", item.id) + val fetchedItem = + api.userLibraryApi + .getItem(item.id) + .content + .let { BaseItem(it) } + if (!shouldResolveStrm(fetchedItem, sourceId)) { + Timber.d("Got updated streams for %s", item.id) + return fetchedItem + } + delay(2.seconds) + count-- + } + Timber.w("Resolve timed out for %s", item.id) + throw TimeoutException("Resolve timed out for " + item.id) + } + return null + } + + private suspend fun resolve( + itemId: UUID, + mediaSourceId: String?, + ) { + val response by + api.mediaInfoApi + .getPostedPlaybackInfo( + itemId, + PlaybackInfoDto( + deviceProfile = null, + mediaSourceId = mediaSourceId, + autoOpenLiveStream = false, + ), + ) + if (response.errorCode != null) { + throw IllegalStateException("Error: " + response.errorCode) + } + } + + companion object { + fun isStrmFile(item: BaseItem): Boolean = item.data.path?.endsWith(".strm") == true + + fun shouldResolveStrm( + item: BaseItem, + sourceId: String? = null, + ): Boolean { + if (!isStrmFile(item)) { + return false + } + val source = + sourceId?.let { item.data.mediaSources?.firstOrNull { it.id == sourceId } } + ?: item.data.mediaSources?.firstOrNull() + val streams = source?.mediaStreams + return streams.isNullOrEmpty() + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 4137759d5..bc74add22 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -100,6 +100,12 @@ fun ExpandablePlayButtons( firstFocus.tryRequestFocus() } } + val hasStreams = + remember(chooseVersionParams) { + chooseVersionParams?.chosenStreams?.source.let { + it == null || !it.mediaStreams.isNullOrEmpty() + } + } var chooseVersion by remember { mutableStateOf(null) } @@ -117,6 +123,7 @@ fun ExpandablePlayButtons( resume = resumePosition, icon = Icons.Default.PlayArrow, onClick = playOnClick, + enabled = hasStreams, modifier = Modifier .onFocusChanged(buttonOnFocusChanged) @@ -130,6 +137,7 @@ fun ExpandablePlayButtons( resume = Duration.ZERO, icon = Icons.Default.Refresh, onClick = playOnClick, + enabled = hasStreams, modifier = Modifier.onFocusChanged(buttonOnFocusChanged), mirrorIcon = true, interactionSource = restartInteractionSource, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index ef94620ab..e10cdf3b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.StrmFileHandler import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem @@ -58,6 +59,7 @@ class EpisodeViewModel private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val mediaManagementService: MediaManagementService, + private val strmFileHandler: StrmFileHandler, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -71,7 +73,7 @@ class EpisodeViewModel val canDelete = MutableStateFlow(false) init { - init() +// init() viewModelScope.launchDefault { mediaManagementService.collectCanDelete( state.map { (it.episode as? DataLoadingState.Success)?.data }, @@ -115,6 +117,28 @@ class EpisodeViewModel ) } backdropService.submit(item) + viewModelScope.launchIO { + try { + val result = strmFileHandler.resolveStrm(item) + if (result != null) { + Timber.d("Got updated item") + val chosenStreams = + itemPlaybackRepository.getSelectedTracks( + itemId, + result, + userPreferencesService.getCurrent(), + ) + _state.update { + it.copy( + episode = DataLoadingState.Success(result), + chosenStreams = chosenStreams, + ) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error checking strm file for %s", item.id) + } + } } catch (ex: CancellationException) { throw ex } catch (ex: Exception) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 9df284250..94b5ace97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.StrmFileHandler import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService @@ -71,6 +72,7 @@ class MovieViewModel private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val mediaManagementService: MediaManagementService, + private val strmFileHandler: StrmFileHandler, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -82,7 +84,7 @@ class MovieViewModel val state: StateFlow = _state init { - init() +// init() viewModelScope.launchDefault { mediaManagementService.collectCanDelete(state.map { it.movie }) { canDelete -> _state.update { @@ -176,6 +178,29 @@ class MovieViewModel _state.update { it.copy(similar = similar) } } + + viewModelScope.launchIO { + try { + val result = strmFileHandler.resolveStrm(movie) + if (result != null) { + Timber.d("Got updated item") + val chosenStreams = + itemPlaybackRepository.getSelectedTracks( + itemId, + result, + userPreferencesService.getCurrent(), + ) + _state.update { + it.copy( + loading = DataLoadingState.Success(result), + chosenStreams = chosenStreams, + ) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error checking strm file for %s", movie.id) + } + } } fun setWatched( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index fed2d3424..0662471a0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -148,6 +148,7 @@ fun SeriesOverview( ) } + // TODO move this to the viewmodel on position updates LaunchedEffect(position, state.episodes) { val focusedEpisode = (state.episodes as? EpisodeList.Success) @@ -157,6 +158,7 @@ fun SeriesOverview( focusedEpisode?.let { viewModel.lookUpChosenTracks(it.id, it) viewModel.lookupPeopleInEpisode(it) + viewModel.resolveStrm(position.episodeRowIndex, it) } } val chosenStreams = state.chosenStreams diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 499dc44fb..ec4d9913e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.StrmFileHandler import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService @@ -99,6 +100,7 @@ class SeriesViewModel private val backdropService: BackdropService, private val seerrService: SeerrService, private val mediaManagementService: MediaManagementService, + private val strmFileHandler: StrmFileHandler, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -528,6 +530,28 @@ class SeriesViewModel } } + private var strmResolveJob: Job? = null + + fun resolveStrm( + index: Int, + item: BaseItem, + ) { + strmResolveJob?.cancel() + strmResolveJob = + viewModelScope.launchIO { + try { + val result = strmFileHandler.resolveStrm(item) + if (result != null) { + Timber.d("Got updated item") + refreshEpisode(item.id, index).join() + lookUpChosenTracks(item.id, result) + } + } catch (ex: Exception) { + Timber.e(ex, "Error checking strm file for %s", item.id) + } + } + } + fun savePlayVersion( item: BaseItem, sourceId: UUID, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index fbc694d5d..de83528da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.StrmFileHandler import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -151,6 +152,7 @@ class PlaybackViewModel private val imageUrlService: ImageUrlService, private val screensaverService: ScreensaverService, private val musicService: MusicService, + private val strmFileHandler: StrmFileHandler, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -430,6 +432,13 @@ class PlaybackViewModel Timber.i("Playing ${item.id}") + try { + strmFileHandler.resolveStrm(item) + } catch (ex: Exception) { + Timber.e(ex, "strm file error playing %s", item.id) + return@withContext false + } + // New item, so we can clear the media segment tracker & subtitle cues resetSegmentState() _state.update { it.copy(subtitleCues = emptyList()) }