feat: Download transcoded media

This commit is contained in:
nomadics9 2024-07-19 03:44:43 +03:00
parent ccc6788a02
commit 062781a43d
7 changed files with 357 additions and 136 deletions

View file

@ -157,70 +157,80 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}
binding.itemActions.downloadButton.setOnClickListener {
if (viewModel.item.isDownloaded()) {
viewModel.deleteEpisode()
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
} else if (viewModel.item.isDownloading()) {
createCancelDialog()
} else {
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true
binding.itemActions.progressDownload.isVisible = true
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
val storageDialog = getStorageSelectionDialog(
requireContext(),
onItemSelected = { storageIndex ->
if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog(
requireContext(),
viewModel.item,
onItemSelected = { sourceIndex ->
createDownloadPreparingDialog()
viewModel.download(sourceIndex, storageIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
dialog.show()
return@getStorageSelectionDialog
}
createDownloadPreparingDialog()
viewModel.download(storageIndex = storageIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
storageDialog.show()
return@setOnClickListener
}
if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog(
requireContext(),
viewModel.item,
onItemSelected = { sourceIndex ->
createDownloadPreparingDialog()
viewModel.download(sourceIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
dialog.show()
return@setOnClickListener
}
createDownloadPreparingDialog()
viewModel.download()
}
handleDownload()
}
return binding.root
}
private fun handleDownload() {
if (viewModel.item.isDownloaded()) {
viewModel.deleteEpisode()
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
} else if (viewModel.item.isDownloading()) {
createCancelDialog()
}else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog()
} else {
download()
}
}
private fun download(){
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true
binding.itemActions.progressDownload.isVisible = true
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
val storageDialog = getStorageSelectionDialog(
requireContext(),
onItemSelected = { storageIndex ->
if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog(
requireContext(),
viewModel.item,
onItemSelected = { sourceIndex ->
createDownloadPreparingDialog()
viewModel.download(sourceIndex, storageIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
dialog.show()
return@getStorageSelectionDialog
}
createDownloadPreparingDialog()
viewModel.download(storageIndex = storageIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
storageDialog.show()
return
}
if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog(
requireContext(),
viewModel.item,
onItemSelected = { sourceIndex ->
createDownloadPreparingDialog()
viewModel.download(sourceIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
dialog.show()
return
}
createDownloadPreparingDialog()
viewModel.download()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
dialog?.let {
val sheet = it as BottomSheetDialog
@ -402,6 +412,31 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
dialog.show()
}
private fun createPickQualityDialog() {
val qualityEntries = resources.getStringArray(CoreR.array.quality_entries)
val qualityValues = resources.getStringArray(CoreR.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()
download()
}
builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
val dialog = builder.create()
dialog.show()
}
private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>,
) {

View file

@ -192,65 +192,7 @@ class MovieFragment : Fragment() {
}
binding.itemActions.downloadButton.setOnClickListener {
if (viewModel.item.isDownloaded()) {
viewModel.deleteItem()
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
} else if (viewModel.item.isDownloading()) {
createCancelDialog()
} else {
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true
binding.itemActions.progressDownload.isVisible = true
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
val storageDialog = getStorageSelectionDialog(
requireContext(),
onItemSelected = { storageIndex ->
if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog(
requireContext(),
viewModel.item,
onItemSelected = { sourceIndex ->
createDownloadPreparingDialog()
viewModel.download(sourceIndex, storageIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
dialog.show()
return@getStorageSelectionDialog
}
createDownloadPreparingDialog()
viewModel.download(storageIndex = storageIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
storageDialog.show()
return@setOnClickListener
}
if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog(
requireContext(),
viewModel.item,
onItemSelected = { sourceIndex ->
createDownloadPreparingDialog()
viewModel.download(sourceIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
dialog.show()
return@setOnClickListener
}
createDownloadPreparingDialog()
viewModel.download()
}
handleDownload()
}
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
@ -258,6 +200,74 @@ class MovieFragment : Fragment() {
}
}
private fun handleDownload() {
if (viewModel.item.isDownloaded()) {
viewModel.deleteItem()
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
} else if (viewModel.item.isDownloading()) {
createCancelDialog()
} else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog()
} else {
download()
}
}
private fun download() {
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true
binding.itemActions.progressDownload.isVisible = true
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
val storageDialog = getStorageSelectionDialog(
requireContext(),
onItemSelected = { storageIndex ->
if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog(
requireContext(),
viewModel.item,
onItemSelected = { sourceIndex ->
createDownloadPreparingDialog()
viewModel.download(sourceIndex, storageIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
dialog.show()
return@getStorageSelectionDialog
}
createDownloadPreparingDialog()
viewModel.download(storageIndex = storageIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
storageDialog.show()
return
}
if (viewModel.item.sources.size > 1) {
val dialog = getVideoVersionDialog(
requireContext(),
viewModel.item,
onItemSelected = { sourceIndex ->
createDownloadPreparingDialog()
viewModel.download(sourceIndex)
},
onCancel = {
binding.itemActions.progressDownload.isVisible = false
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
},
)
dialog.show()
return
}
createDownloadPreparingDialog()
viewModel.download()
}
override fun onResume() {
super.onResume()
@ -495,6 +505,31 @@ class MovieFragment : Fragment() {
dialog.show()
}
private fun createPickQualityDialog() {
val qualityEntries = resources.getStringArray(CoreR.array.quality_entries)
val qualityValues = resources.getStringArray(CoreR.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
download()
dialog.dismiss()
}
builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
val dialog = builder.create()
dialog.show()
}
private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>,
) {

View file

@ -26,6 +26,8 @@ import dev.jdtech.jellyfin.models.toFindroidTrickplayInfoDto
import dev.jdtech.jellyfin.models.toFindroidUserDataDto
import dev.jdtech.jellyfin.models.toIntroDto
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamType
import java.io.File
import java.util.UUID
import kotlin.Exception
@ -82,15 +84,29 @@ class DownloaderImpl(
if (intro != null) {
database.insertIntro(intro.toIntroDto(item.id))
}
val request = DownloadManager.Request(source.path.toUri())
.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)
if (appPreferences.downloadQuality != "Original") {
downloadEmbeddedMediaStreams(item, source,storageIndex)
val transcodingUrl =getTranscodedUrl(item.id,appPreferences.downloadQuality!!)
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)
}else {
val request = DownloadManager.Request(source.path.toUri())
.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 -> {
@ -111,15 +127,29 @@ class DownloaderImpl(
if (intro != null) {
database.insertIntro(intro.toIntroDto(item.id))
}
val request = DownloadManager.Request(source.path.toUri())
.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)
if (appPreferences.downloadQuality != "Original") {
downloadEmbeddedMediaStreams(item, source,storageIndex)
val transcodingUrl = getTranscodedUrl(item.id, appPreferences.downloadQuality!!)
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)
}else {
val request = DownloadManager.Request(source.path.toUri())
.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)
@ -230,6 +260,45 @@ class DownloaderImpl(
}
}
private fun downloadEmbeddedMediaStreams(
item: FindroidItem,
source: FindroidSource,
storageIndex: Int = 0
) {
val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
val subtitleStreams = source.mediaStreams.filter { !it.isExternal && it.type == MediaStreamType.SUBTITLE && it.path != null }
for (mediaStream in subtitleStreams) {
var deliveryUrl = mediaStream.path!!
if (mediaStream.codec == "webvtt") {
deliveryUrl = deliveryUrl.replace("Stream.srt", "Stream.vtt")
}
val id = UUID.randomUUID()
val streamPath = Uri.fromFile(
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(deliveryUrl))
.setTitle(mediaStream.title)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
.setDestinationUri(streamPath)
val downloadId = downloadManager.enqueue(request)
database.setMediaStreamDownloadId(id, downloadId)
}
}
private suspend fun downloadTrickplayData(
itemId: UUID,
sourceId: String,
@ -263,4 +332,47 @@ class DownloaderImpl(
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
}
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 deviceId = jellyfinRepository.getDeviceId()
val downloadUrl = jellyfinRepository.getVideoStreambyContainerUrl(itemId, deviceId, mediaSourceId, playSessionId, maxBitrate, "ts")
val transcodeUri = buildTranscodeUri(downloadUrl, maxBitrate, quality)
transcodeUri
} catch (e: Exception) {
null
}
}
// TODO: I believe building upon the uri is not necessary anymore all is handled in the sdk api
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")
.build()
}
}

View file

@ -25,4 +25,16 @@
<item>audiotrack</item>
<item>opensles</item>
</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>

View file

@ -9,4 +9,16 @@
android:defaultValue="false"
app:key="pref_downloads_roaming"
app:title="@string/download_roaming" />
<!-- TODO: Strings -->
<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>

View file

@ -123,6 +123,19 @@ constructor(
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
var sortBy: String
get() = sharedPreferences.getString(

View file

@ -42,6 +42,8 @@ object Constants {
const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout"
const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data"
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_ORDER = "pref_sort_order"
const val PREF_DISPLAY_EXTRA_INFO = "pref_display_extra_info"