feat: Transcoding Download / code: Cleanup
Some checks failed
Build / Assemble (push) Has been cancelled

This commit is contained in:
nomadics9 2024-07-17 05:58:26 +03:00
parent afbf68937d
commit 362201eddf
14 changed files with 694 additions and 304 deletions

View file

@ -157,11 +157,20 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
} }
binding.itemActions.downloadButton.setOnClickListener { binding.itemActions.downloadButton.setOnClickListener {
handleDownload()
}
return binding.root
}
private fun handleDownload() {
if (viewModel.item.isDownloaded()) { if (viewModel.item.isDownloaded()) {
viewModel.deleteEpisode() viewModel.deleteEpisode()
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download) binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
} else if (viewModel.item.isDownloading()) { } else if (viewModel.item.isDownloading()) {
createCancelDialog() createCancelDialog()
}else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog()
} else { } else {
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent) binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true binding.itemActions.progressDownload.isIndeterminate = true
@ -195,7 +204,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}, },
) )
storageDialog.show() storageDialog.show()
return@setOnClickListener return
} }
if (viewModel.item.sources.size > 1) { if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog( val dialog = getVideoVersionDialog(
@ -211,16 +220,13 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}, },
) )
dialog.show() dialog.show()
return@setOnClickListener return
} }
createDownloadPreparingDialog() createDownloadPreparingDialog()
viewModel.download() viewModel.download()
} }
} }
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
dialog?.let { dialog?.let {
val sheet = it as BottomSheetDialog val sheet = it as BottomSheetDialog
@ -402,6 +408,31 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
dialog.show() dialog.show()
} }
private fun createPickQualityDialog() {
val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
val quality = appPreferences.downloadQuality
val currentQualityIndex = qualityValues.indexOf(quality)
var selectedQuality = quality
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle("Download Quality")
builder.setSingleChoiceItems(qualityEntries, currentQualityIndex) { _, which ->
selectedQuality = qualityValues[which]
}
builder.setPositiveButton("Download") { dialog, _ ->
appPreferences.downloadQuality = selectedQuality
dialog.dismiss()
handleDownload()
}
builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
val dialog = builder.create()
dialog.show()
}
private fun navigateToPlayerActivity( private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>, playerItems: Array<PlayerItem>,
) { ) {

View file

@ -192,11 +192,22 @@ class MovieFragment : Fragment() {
} }
binding.itemActions.downloadButton.setOnClickListener { binding.itemActions.downloadButton.setOnClickListener {
handleDownload()
}
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
navigateToPersonDetail(person.id)
}
}
private fun handleDownload() {
if (viewModel.item.isDownloaded()) { if (viewModel.item.isDownloaded()) {
viewModel.deleteItem() viewModel.deleteItem()
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download) binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
} else if (viewModel.item.isDownloading()) { } else if (viewModel.item.isDownloading()) {
createCancelDialog() createCancelDialog()
} else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog()
} else { } else {
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent) binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true binding.itemActions.progressDownload.isIndeterminate = true
@ -230,7 +241,7 @@ class MovieFragment : Fragment() {
}, },
) )
storageDialog.show() storageDialog.show()
return@setOnClickListener return
} }
if (viewModel.item.sources.size > 1) { if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog( val dialog = getVideoVersionDialog(
@ -246,18 +257,13 @@ class MovieFragment : Fragment() {
}, },
) )
dialog.show() dialog.show()
return@setOnClickListener return
} }
createDownloadPreparingDialog() createDownloadPreparingDialog()
viewModel.download() viewModel.download()
} }
} }
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
navigateToPersonDetail(person.id)
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@ -495,6 +501,31 @@ class MovieFragment : Fragment() {
dialog.show() dialog.show()
} }
private fun createPickQualityDialog() {
val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
val quality = appPreferences.downloadQuality
val currentQualityIndex = qualityValues.indexOf(quality)
var selectedQuality = quality
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle("Download Quality")
builder.setSingleChoiceItems(qualityEntries, currentQualityIndex) { _, which ->
selectedQuality = qualityValues[which]
}
builder.setPositiveButton("Download") { dialog, _ ->
appPreferences.downloadQuality = selectedQuality
dialog.dismiss()
handleDownload()
}
builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
val dialog = builder.create()
dialog.show()
}
private fun navigateToPlayerActivity( private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>, playerItems: Array<PlayerItem>,
) { ) {

View file

@ -31,7 +31,9 @@ import timber.log.Timber
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.models.UiText import com.nomadics9.ananas.models.UiText
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -44,6 +46,9 @@ class SeasonFragment : Fragment() {
private lateinit var errorDialog: ErrorDialogFragment private lateinit var errorDialog: ErrorDialogFragment
private lateinit var downloadPreparingDialog: AlertDialog private lateinit var downloadPreparingDialog: AlertDialog
@Inject
lateinit var appPreferences: AppPreferences
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -195,6 +200,15 @@ class SeasonFragment : Fragment() {
} }
private fun createEpisodesToDownloadDialog(storageIndex: Int = 0) { private fun createEpisodesToDownloadDialog(storageIndex: Int = 0) {
if (!appPreferences.downloadQualityDefault)
createPickQualityDialog {
showDownloadDialog(storageIndex)
} else {
showDownloadDialog(storageIndex)
}
}
private fun showDownloadDialog(storageIndex: Int = 0) {
val builder = MaterialAlertDialogBuilder(requireContext()) val builder = MaterialAlertDialogBuilder(requireContext())
val dialog = builder val dialog = builder
.setTitle(com.nomadics9.ananas.core.R.string.download_season_dialog_title) .setTitle(com.nomadics9.ananas.core.R.string.download_season_dialog_title)
@ -211,6 +225,33 @@ class SeasonFragment : Fragment() {
dialog.show() dialog.show()
} }
private fun createPickQualityDialog(onQualitySelected: () -> Unit) {
val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
val quality = appPreferences.downloadQuality
val currentQualityIndex = qualityValues.indexOf(quality)
var selectedQuality = quality
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle("Download Quality")
builder.setSingleChoiceItems(qualityEntries, currentQualityIndex) { _, which ->
selectedQuality = qualityValues[which]
}
builder.setPositiveButton("Download") { dialog, _ ->
appPreferences.downloadQuality = selectedQuality
dialog.dismiss()
onQualitySelected()
}
builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
val dialog = builder.create()
dialog.show()
}

View file

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Select Quality"
/>
<Button
android:id="@+id/btnQuality1080"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1080p" />
<Button
android:id="@+id/btnQuality720"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="720p"
/>
<Button
android:id="@+id/btnQuality480"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="480p"
/>
<Button
android:id="@+id/btnQuality360"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="360p"
/>
</LinearLayout>

View file

@ -6,6 +6,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import com.nomadics9.ananas.AppPreferences import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.api.JellyfinApi
import com.nomadics9.ananas.database.ServerDatabaseDao import com.nomadics9.ananas.database.ServerDatabaseDao
import com.nomadics9.ananas.repository.JellyfinRepository import com.nomadics9.ananas.repository.JellyfinRepository
import com.nomadics9.ananas.utils.Downloader import com.nomadics9.ananas.utils.Downloader

View file

@ -6,12 +6,15 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import android.text.format.Formatter import android.text.format.Formatter
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import com.nomadics9.ananas.AppPreferences import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.api.JellyfinApi
import com.nomadics9.ananas.database.ServerDatabaseDao import com.nomadics9.ananas.database.ServerDatabaseDao
import com.nomadics9.ananas.models.FindroidEpisode import com.nomadics9.ananas.models.FindroidEpisode
import com.nomadics9.ananas.models.FindroidItem import com.nomadics9.ananas.models.FindroidItem
import com.nomadics9.ananas.models.FindroidMovie import com.nomadics9.ananas.models.FindroidMovie
import com.nomadics9.ananas.models.FindroidSegment
import com.nomadics9.ananas.models.FindroidSource import com.nomadics9.ananas.models.FindroidSource
import com.nomadics9.ananas.models.FindroidSources import com.nomadics9.ananas.models.FindroidSources
import com.nomadics9.ananas.models.FindroidTrickplayInfo import com.nomadics9.ananas.models.FindroidTrickplayInfo
@ -26,7 +29,34 @@ import com.nomadics9.ananas.models.toFindroidSourceDto
import com.nomadics9.ananas.models.toFindroidTrickplayInfoDto import com.nomadics9.ananas.models.toFindroidTrickplayInfoDto
import com.nomadics9.ananas.models.toFindroidUserDataDto import com.nomadics9.ananas.models.toFindroidUserDataDto
import com.nomadics9.ananas.repository.JellyfinRepository import com.nomadics9.ananas.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.extensions.dynamicHlsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.ProfileCondition
import org.jellyfin.sdk.model.api.ProfileConditionType
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
import org.jellyfin.sdk.model.api.TranscodingProfile
import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.URL
import java.util.UUID import java.util.UUID
import kotlin.Exception import kotlin.Exception
import kotlin.math.ceil import kotlin.math.ceil
@ -46,7 +76,11 @@ class DownloaderImpl(
storageIndex: Int, storageIndex: Int,
): Pair<Long, UiText?> { ): Pair<Long, UiText?> {
try { try {
val source = jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId }
Timber.d("Downloading item: ${item.id} with sourceId: $sourceId")
val source =
jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId }
val segments = jellyfinRepository.getSegmentsTimestamps(item.id) val segments = jellyfinRepository.getSegmentsTimestamps(item.id)
val trickplayInfo = if (item is FindroidSources) { val trickplayInfo = if (item is FindroidSources) {
item.trickplayInfo?.get(sourceId) item.trickplayInfo?.get(sourceId)
@ -70,6 +104,41 @@ class DownloaderImpl(
), ),
) )
} }
val qualityPreference = appPreferences.downloadQuality!!
Timber.d("Quality preference: $qualityPreference")
return if (qualityPreference != "Original") {
Timber.d("Handling Transcoding download for item: ${item.id}")
handleTranscodeDownload(item, source, storageIndex, trickplayInfo, segments, path, qualityPreference)
} else {
Timber.d("Handling original download for item: ${item.id}")
downloadOriginalItem(item, source, storageIndex, trickplayInfo, segments, path)
}
} catch (e: Exception) {
try {
val source = jellyfinRepository.getMediaSources(item.id).first { it.id == sourceId }
deleteItem(item, source)
} catch (_: Exception) {
}
return Pair(
-1,
if (e.message != null) UiText.DynamicString(e.message!!) else UiText.StringResource(
CoreR.string.unknown_error
)
)
}
}
private suspend fun handleTranscodeDownload(
item: FindroidItem,
source: FindroidSource,
storageIndex: Int,
trickplayInfo: FindroidTrickplayInfo?,
segments: List<FindroidSegment>?,
path: Uri,
quality: String
): Pair<Long, UiText?> {
val transcodingUrl = getTranscodedUrl(item.id, quality)
when (item) { when (item) {
is FindroidMovie -> { is FindroidMovie -> {
database.insertMovie(item.toFindroidMovieDto(appPreferences.currentServer!!)) database.insertMovie(item.toFindroidMovieDto(appPreferences.currentServer!!))
@ -77,7 +146,70 @@ class DownloaderImpl(
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId())) database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex) downloadExternalMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) { if (trickplayInfo != null) {
downloadTrickplayData(item.id, sourceId, trickplayInfo) downloadTrickplayData(item.id, source.id, trickplayInfo)
}
if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id))
}
val request = DownloadManager.Request(transcodingUrl)
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
}
is FindroidEpisode -> {
database.insertShow(
jellyfinRepository.getShow(item.seriesId)
.toFindroidShowDto(appPreferences.currentServer!!),
)
database.insertSeason(
jellyfinRepository.getSeason(item.seasonId).toFindroidSeasonDto(),
)
database.insertEpisode(item.toFindroidEpisodeDto(appPreferences.currentServer!!))
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) {
downloadTrickplayData(item.id, source.id, trickplayInfo)
}
if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id))
}
val request = DownloadManager.Request(transcodingUrl)
.setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
}
}
return Pair(-1, null)
}
private suspend fun downloadOriginalItem(
item: FindroidItem,
source: FindroidSource,
storageIndex: Int,
trickplayInfo: FindroidTrickplayInfo?,
segments: List<FindroidSegment>?,
path: Uri,
): Pair<Long, UiText?> {
when (item) {
is FindroidMovie -> {
database.insertMovie(item.toFindroidMovieDto(appPreferences.currentServer!!))
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) {
downloadTrickplayData(item.id, source.id, trickplayInfo)
} }
if (segments != null) { if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id)) database.insertSegments(segments.toFindroidSegmentsDto(item.id))
@ -106,7 +238,7 @@ class DownloaderImpl(
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId())) database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex) downloadExternalMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) { if (trickplayInfo != null) {
downloadTrickplayData(item.id, sourceId, trickplayInfo) downloadTrickplayData(item.id, source.id, trickplayInfo)
} }
if (segments != null) { if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id)) database.insertSegments(segments.toFindroidSegmentsDto(item.id))
@ -123,15 +255,8 @@ class DownloaderImpl(
} }
} }
return Pair(-1, null) return Pair(-1, null)
} catch (e: Exception) { }
try {
val source = jellyfinRepository.getMediaSources(item.id).first { it.id == sourceId }
deleteItem(item, source)
} catch (_: Exception) {}
return Pair(-1, if (e.message != null) UiText.DynamicString(e.message!!) else UiText.StringResource(CoreR.string.unknown_error))
}
}
override suspend fun cancelDownload(item: FindroidItem, source: FindroidSource) { override suspend fun cancelDownload(item: FindroidItem, source: FindroidSource) {
if (source.downloadId != null) { if (source.downloadId != null) {
@ -193,9 +318,11 @@ class DownloaderImpl(
) )
when (downloadStatus) { when (downloadStatus) {
DownloadManager.STATUS_RUNNING -> { DownloadManager.STATUS_RUNNING -> {
val totalBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) val totalBytes =
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
if (totalBytes > 0) { if (totalBytes > 0) {
val downloadedBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) val downloadedBytes =
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
progress = downloadedBytes.times(100).div(totalBytes).toInt() progress = downloadedBytes.times(100).div(totalBytes).toInt()
} }
} }
@ -217,8 +344,19 @@ class DownloaderImpl(
val storageLocation = context.getExternalFilesDirs(null)[storageIndex] val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
for (mediaStream in source.mediaStreams.filter { it.isExternal }) { for (mediaStream in source.mediaStreams.filter { it.isExternal }) {
val id = UUID.randomUUID() val id = UUID.randomUUID()
val streamPath = Uri.fromFile(File(storageLocation, "downloads/${item.id}.${source.id}.$id.download")) val streamPath = Uri.fromFile(
database.insertMediaStream(mediaStream.toFindroidMediaStreamDto(id, source.id, streamPath.path.orEmpty())) File(
storageLocation,
"downloads/${item.id}.${source.id}.$id.download"
)
)
database.insertMediaStream(
mediaStream.toFindroidMediaStreamDto(
id,
source.id,
streamPath.path.orEmpty()
)
)
val request = DownloadManager.Request(Uri.parse(mediaStream.path)) val request = DownloadManager.Request(Uri.parse(mediaStream.path))
.setTitle(mediaStream.title) .setTitle(mediaStream.title)
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
@ -235,7 +373,10 @@ class DownloaderImpl(
sourceId: String, sourceId: String,
trickplayInfo: FindroidTrickplayInfo, trickplayInfo: FindroidTrickplayInfo,
) { ) {
val maxIndex = ceil(trickplayInfo.thumbnailCount.toDouble().div(trickplayInfo.tileWidth * trickplayInfo.tileHeight)).toInt() val maxIndex = ceil(
trickplayInfo.thumbnailCount.toDouble()
.div(trickplayInfo.tileWidth * trickplayInfo.tileHeight)
).toInt()
val byteArrays = mutableListOf<ByteArray>() val byteArrays = mutableListOf<ByteArray>()
for (i in 0..maxIndex) { for (i in 0..maxIndex) {
jellyfinRepository.getTrickplayData( jellyfinRepository.getTrickplayData(
@ -263,4 +404,52 @@ class DownloaderImpl(
file.writeBytes(byteArray) file.writeBytes(byteArray)
} }
} }
private suspend fun getTranscodedUrl(itemId: UUID, quality: String): Uri? {
val maxBitrate = when (quality) {
"720p" -> 2000000 // 2 Mbps
"480p" -> 1000000 // 1 Mbps
"360p" -> 800000 // 800Kbps
else -> 2000000 // Default to 2 Mbps if not specified
} }
return try {
val deviceProfile = jellyfinRepository.buildDeviceProfile(maxBitrate,"mkv", EncodingContext.STATIC)
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,false,deviceProfile,maxBitrate)
val mediaSourceId = playbackInfo.content.mediaSources.firstOrNull()?.id!!
val playSessionId = playbackInfo.content.playSessionId!!
val downloadUrl = jellyfinRepository.getVideoStreambyContainerUrl(itemId, mediaSourceId, playSessionId, maxBitrate, "ts")
val transcodeUri = buildTranscodeUri(downloadUrl, maxBitrate, quality)
Timber.d("Constructed Transcode URL: $transcodeUri")
transcodeUri
} catch (e: Exception) {
Timber.e(e)
null
}
}
private fun buildTranscodeUri(
transcodingUrl: String,
maxBitrate: Int,
quality: String
): Uri {
val resolution = when (quality) {
"720p" -> "720"
"480p" -> "480"
"360p" -> "360"
else -> "720"
}
return Uri.parse(transcodingUrl).buildUpon()
.appendQueryParameter("MaxVideoHeight", resolution)
.appendQueryParameter("MaxVideoBitRate", maxBitrate.toString())
.appendQueryParameter("subtitleMethod", "External")
//.appendQueryParameter("api_key", apiKey)
.build()
}
}

View file

@ -25,4 +25,16 @@
<item>audiotrack</item> <item>audiotrack</item>
<item>opensles</item> <item>opensles</item>
</string-array> </string-array>
<string-array name="quality_entries">
<item>Original</item>
<item>720p - 2Mbps</item>
<item>480p - 1Mbps</item>
<item>360p - 800Kbps</item>
</string-array>
<string-array name="quality_values">
<item>Original</item>
<item>720p</item>
<item>480p</item>
<item>360p</item>
</string-array>
</resources> </resources>

View file

@ -9,4 +9,17 @@
android:defaultValue="false" android:defaultValue="false"
app:key="pref_downloads_roaming" app:key="pref_downloads_roaming"
app:title="@string/download_roaming" /> app:title="@string/download_roaming" />
<ListPreference
android:key="pref_downloads_quality"
android:title="Download Quality"
android:defaultValue="Original"
android:entries="@array/quality_entries"
android:entryValues="@array/quality_values"
android:summary="%s" />
<SwitchPreferenceCompat
android:defaultValue="false"
app:key="pref_downloads_quality_default"
app:summary="Default to picked Download Quality" />
</PreferenceScreen> </PreferenceScreen>

View file

@ -11,9 +11,14 @@ import com.nomadics9.ananas.models.FindroidShow
import com.nomadics9.ananas.models.FindroidSource import com.nomadics9.ananas.models.FindroidSource
import com.nomadics9.ananas.models.SortBy import com.nomadics9.ananas.models.SortBy
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceInfoQueryResult
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
@ -113,5 +118,15 @@ interface JellyfinRepository {
fun getUserId(): UUID fun getUserId(): UUID
fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int?, Int?> suspend fun getDeviceId(): String
suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int>
suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile
suspend fun getVideoStreambyContainerUrl(itemId: UUID, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String
suspend fun getPostedPlaybackInfo(itemId: UUID, enableDirectStream: Boolean, deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse>
suspend fun stopEncodingProcess(playSessionId: String)
} }

View file

@ -29,23 +29,35 @@ import io.ktor.util.toByteArray
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.get import org.jellyfin.sdk.api.client.extensions.get
import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
import org.jellyfin.sdk.model.api.DeviceInfoQueryResult
import org.jellyfin.sdk.model.api.DeviceOptionsDto import org.jellyfin.sdk.model.api.DeviceOptionsDto
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.GeneralCommandType import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemFilter import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.ProfileCondition
import org.jellyfin.sdk.model.api.ProfileConditionType
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
import org.jellyfin.sdk.model.api.TranscodingProfile
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -566,13 +578,117 @@ class JellyfinRepositoryImpl(
} }
override fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int?, Int?> { override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
return when (transcodeResolution) { return when (transcodeResolution) {
1080 -> 8000000 to 384000 // Adjusted for 1080p 1080 -> 8000000 to 384000 // Adjusted for 1080p
720 -> 2000000 to 384000 // Adjusted for 720p 720 -> 2000000 to 384000 // Adjusted for 720p
480 -> 1000000 to 384000 // Adjusted for 480p 480 -> 1000000 to 384000 // Adjusted for 480p
360 -> 800000 to 128000 // Adjusted for 360p 360 -> 800000 to 128000 // Adjusted for 360p
else -> null to null else -> 8000000 to 384000
} }
} }
override suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile {
val deviceProfile = ClientCapabilitiesDto(
supportedCommands = emptyList(),
playableMediaTypes = emptyList(),
supportsMediaControl = true,
supportsPersistentIdentifier = true,
deviceProfile = DeviceProfile(
name = "AnanasUser",
id = getUserId().toString(),
maxStaticBitrate = maxBitrate,
maxStreamingBitrate = maxBitrate,
codecProfiles = emptyList(),
containerProfiles = listOf(),
directPlayProfiles = listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO),
),
transcodingProfiles = listOf(
TranscodingProfile(
container = container,
context = context,
protocol = MediaStreamProtocol.HLS,
audioCodec = "aac,ac3,eac3",
videoCodec = "hevc,h264",
type = DlnaProfileType.VIDEO,
conditions = listOf(
ProfileCondition(
condition = ProfileConditionType.LESS_THAN_EQUAL,
property = ProfileConditionValue.VIDEO_BITRATE,
value = "8000000",
isRequired = true,
)
),
copyTimestamps = true,
enableSubtitlesInManifest = true,
transcodeSeekInfo = TranscodeSeekInfo.AUTO,
),
),
subtitleProfiles = listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("pgs", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvb_teletext", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvd_subtitle", SubtitleDeliveryMethod.EXTERNAL)
),
)
)
return deviceProfile.deviceProfile!!
} }
override suspend fun getPostedPlaybackInfo(itemId: UUID ,enableDirectStream: Boolean ,deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse> {
val playbackInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
itemId = itemId,
PlaybackInfoDto(
userId = jellyfinApi.userId!!,
enableTranscoding = true,
enableDirectPlay = false,
enableDirectStream = enableDirectStream,
autoOpenLiveStream = true,
deviceProfile = deviceProfile,
allowAudioStreamCopy = true,
allowVideoStreamCopy = true,
maxStreamingBitrate = maxBitrate,
)
)
return playbackInfo
}
override suspend fun getVideoStreambyContainerUrl(itemId: UUID, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String {
val url = jellyfinApi.videosApi.getVideoStreamByContainerUrl(
itemId,
static = false,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
videoBitRate = videoBitrate,
audioBitRate = 384000,
videoCodec = "hevc",
audioCodec = "aac,ac3,eac3",
container = container,
startTimeTicks = 0,
copyTimestamps = true,
)
return url
}
override suspend fun getDeviceId(): String {
val deviceId = jellyfinApi.devicesApi.getDevices(getUserId())
return deviceId.toString()
}
override suspend fun stopEncodingProcess(playSessionId: String) {
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
deviceId = getDeviceId(),
playSessionId = playSessionId
)
}
}

View file

@ -23,9 +23,13 @@ import com.nomadics9.ananas.models.toFindroidSource
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
@ -286,7 +290,42 @@ class JellyfinRepositoryOfflineImpl(
return jellyfinApi.userId!! return jellyfinApi.userId!!
} }
override fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int?, Int?> { override suspend fun getDeviceId(): String {
TODO("Not yet implemented")
}
override suspend fun buildDeviceProfile(
maxBitrate: Int,
container: String,
context: EncodingContext
): DeviceProfile {
TODO("Not yet implemented")
}
override suspend fun getVideoStreambyContainerUrl(
itemId: UUID,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
container: String
): String {
TODO("Not yet implemented")
}
override suspend fun getPostedPlaybackInfo(
itemId: UUID,
enableDirectStream: Boolean,
deviceProfile: DeviceProfile,
maxBitrate: Int
): Response<PlaybackInfoResponse> {
TODO("Not yet implemented")
}
override suspend fun stopEncodingProcess(playSessionId: String) {
TODO("Not yet implemented")
}
override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
} }

View file

@ -67,7 +67,6 @@ class PlayerActivityViewModel
constructor( constructor(
private val application: Application, private val application: Application,
private val jellyfinRepository: JellyfinRepository, private val jellyfinRepository: JellyfinRepository,
private val jellyfinApi: JellyfinApi,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : ViewModel(), Player.Listener { ) : ViewModel(), Player.Listener {
@ -547,77 +546,11 @@ constructor(
val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate( val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
transcodingResolution transcodingResolution
) )
val deviceProfile = ClientCapabilitiesDto( val deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "ts", EncodingContext.STREAMING)
supportedCommands = emptyList(), val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,true,deviceProfile,videoBitRate)
playableMediaTypes = emptyList(),
supportsMediaControl = true,
supportsPersistentIdentifier = true,
deviceProfile = DeviceProfile(
name = "AnanasUser",
id = jellyfinRepository.getUserId().toString(),
maxStaticBitrate = videoBitRate,
maxStreamingBitrate = videoBitRate,
codecProfiles = emptyList(),
containerProfiles = listOf(),
directPlayProfiles = listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO),
),
transcodingProfiles = listOf(
TranscodingProfile(
container = "ts",
context = EncodingContext.STREAMING,
protocol = MediaStreamProtocol.HLS,
audioCodec = "aac,ac3,eac3",
videoCodec = "hevc,h264",
type = DlnaProfileType.VIDEO,
conditions = listOf(
ProfileCondition(
condition = ProfileConditionType.LESS_THAN_EQUAL,
property = ProfileConditionValue.VIDEO_BITRATE,
value = "8000000",
isRequired = true,
)
),
copyTimestamps = true,
enableSubtitlesInManifest = true,
transcodeSeekInfo = TranscodeSeekInfo.AUTO,
),
),
subtitleProfiles = listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("pgs", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvb_teletext", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvd_subtitle", SubtitleDeliveryMethod.EXTERNAL)
),
)
)
val playbackInfo =
jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
itemId,
PlaybackInfoDto(
userId = jellyfinApi.userId!!,
enableTranscoding = true,
enableDirectPlay = false,
enableDirectStream = true,
autoOpenLiveStream = true,
deviceProfile = deviceProfile.deviceProfile,
maxStreamingBitrate = videoBitRate,
),
)
val playSessionId = playbackInfo.content.playSessionId val playSessionId = playbackInfo.content.playSessionId
val getDeviceId =
jellyfinApi.devicesApi.getDeviceOptions(jellyfinApi.userId.toString())
val deviceId = getDeviceId.content.deviceId
if (playSessionId != null) { if (playSessionId != null) {
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess( jellyfinRepository.stopEncodingProcess(playSessionId)
deviceId,
playSessionId
)
} }
val mediaSource = playbackInfo.content.mediaSources.firstOrNull() val mediaSource = playbackInfo.content.mediaSources.firstOrNull()
if (mediaSource == null) { if (mediaSource == null) {
@ -632,7 +565,7 @@ constructor(
.setMimeType(externalSubtitle.mimeType) .setMimeType(externalSubtitle.mimeType)
.build() .build()
} }
// Timber.tag("MediaStreams").d("Media Streams: %s", mediaSource?.mediaStreams)
// TODO: Embedded sub support // TODO: Embedded sub support
// val embeddedSubtitles = mediaSource?.mediaStreams // val embeddedSubtitles = mediaSource?.mediaStreams
// ?.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal } // ?.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal }
@ -651,18 +584,11 @@ constructor(
// .build() // .build()
// } // }
// ?.toMutableList() ?: mutableListOf() // ?.toMutableList() ?: mutableListOf()
// val allSubtitles = embeddedSubtitles.apply { addAll(mediaSubtitles) } // val allSubtitles = embeddedSubtitles.apply { addAll(mediaSubtitles) }
val baseUrl = jellyfinApi.api.baseUrl
val cleanBaseUrl = baseUrl?.removePrefix("http://")?.removePrefix("https://") val baseUrl = jellyfinRepository.getBaseUrl()
val staticUrl = jellyfinApi.videosApi.getVideoStreamUrl( val cleanBaseUrl = baseUrl.removePrefix("http://").removePrefix("https://")
itemId, val staticUrl = jellyfinRepository.getStreamUrl(itemId, currentItem.mediaSourceId)
static = true,
playSessionId = playSessionId,
deviceId = deviceId,
mediaSourceId = currentItem.mediaSourceId,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL
)
val uri = val uri =
@ -719,6 +645,7 @@ constructor(
uriBuilder.setOrReplaceQueryParameter("VideoBitrate", videoBitRate.toString()) uriBuilder.setOrReplaceQueryParameter("VideoBitrate", videoBitRate.toString())
uriBuilder.setOrReplaceQueryParameter("AudioBitrate", audioBitRate.toString()) uriBuilder.setOrReplaceQueryParameter("AudioBitrate", audioBitRate.toString())
uriBuilder.setOrReplaceQueryParameter("Static", "false") uriBuilder.setOrReplaceQueryParameter("Static", "false")
uriBuilder.appendQueryParameter("PlaySessionId", playSessionId)
uriBuilder.appendQueryParameter( uriBuilder.appendQueryParameter(
"MaxVideoHeight", "MaxVideoHeight",
transcodingResolution.toString() transcodingResolution.toString()

View file

@ -141,6 +141,18 @@ constructor(
false, false,
) )
var downloadQuality get() = sharedPreferences.getString(
Constants.PREF_DOWNLOADS_QUALITY,
"Original")
set(value) {
sharedPreferences.edit().putString(Constants.PREF_DOWNLOADS_QUALITY, value).apply()
}
val downloadQualityDefault get() = sharedPreferences.getBoolean(
Constants.PREF_DOWNLOADS_QUALITY_DEFAULT,
false
)
// Sorting // Sorting
var sortBy: String var sortBy: String
get() = sharedPreferences.getString( get() = sharedPreferences.getString(

View file

@ -45,6 +45,8 @@ object Constants {
const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout" const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout"
const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data" const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data"
const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming" const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming"
const val PREF_DOWNLOADS_QUALITY = "pref_downloads_quality"
const val PREF_DOWNLOADS_QUALITY_DEFAULT = "pref_downloads_quality_default"
const val PREF_SORT_BY = "pref_sort_by" const val PREF_SORT_BY = "pref_sort_by"
const val PREF_SORT_ORDER = "pref_sort_order" const val PREF_SORT_ORDER = "pref_sort_order"
const val PREF_DISPLAY_EXTRA_INFO = "pref_display_extra_info" const val PREF_DISPLAY_EXTRA_INFO = "pref_display_extra_info"