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,70 +157,76 @@ 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 {
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 +408,31 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
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(
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,70 @@ 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 {
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 +501,31 @@ class MovieFragment : Fragment() {
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(
playerItems: Array<PlayerItem>,
) {

View file

@ -31,7 +31,9 @@ import timber.log.Timber
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.models.UiText
import javax.inject.Inject
@AndroidEntryPoint
@ -44,6 +46,9 @@ class SeasonFragment : Fragment() {
private lateinit var errorDialog: ErrorDialogFragment
private lateinit var downloadPreparingDialog: AlertDialog
@Inject
lateinit var appPreferences: AppPreferences
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -195,6 +200,15 @@ class SeasonFragment : Fragment() {
}
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 dialog = builder
.setTitle(com.nomadics9.ananas.core.R.string.download_season_dialog_title)
@ -211,6 +225,33 @@ class SeasonFragment : Fragment() {
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.components.SingletonComponent
import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.api.JellyfinApi
import com.nomadics9.ananas.database.ServerDatabaseDao
import com.nomadics9.ananas.repository.JellyfinRepository
import com.nomadics9.ananas.utils.Downloader

View file

@ -6,12 +6,15 @@ import android.net.Uri
import android.os.Environment
import android.os.StatFs
import android.text.format.Formatter
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.api.JellyfinApi
import com.nomadics9.ananas.database.ServerDatabaseDao
import com.nomadics9.ananas.models.FindroidEpisode
import com.nomadics9.ananas.models.FindroidItem
import com.nomadics9.ananas.models.FindroidMovie
import com.nomadics9.ananas.models.FindroidSegment
import com.nomadics9.ananas.models.FindroidSource
import com.nomadics9.ananas.models.FindroidSources
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.toFindroidUserDataDto
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.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.URL
import java.util.UUID
import kotlin.Exception
import kotlin.math.ceil
@ -46,7 +76,11 @@ class DownloaderImpl(
storageIndex: Int,
): Pair<Long, UiText?> {
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 trickplayInfo = if (item is FindroidSources) {
item.trickplayInfo?.get(sourceId)
@ -70,69 +104,160 @@ class DownloaderImpl(
),
)
}
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, sourceId, trickplayInfo)
}
if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(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)
}
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, sourceId, trickplayInfo)
}
if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(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)
}
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)
}
return Pair(-1, null)
} catch (e: Exception) {
try {
val source = jellyfinRepository.getMediaSources(item.id).first { it.id == sourceId }
deleteItem(item, source)
} catch (_: Exception) {}
} catch (_: Exception) {
}
return Pair(-1, if (e.message != null) UiText.DynamicString(e.message!!) else UiText.StringResource(CoreR.string.unknown_error))
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) {
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) {
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) {
database.insertSegments(segments.toFindroidSegmentsDto(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)
}
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(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)
}
override suspend fun cancelDownload(item: FindroidItem, source: FindroidSource) {
if (source.downloadId != null) {
downloadManager.remove(source.downloadId!!)
@ -193,9 +318,11 @@ class DownloaderImpl(
)
when (downloadStatus) {
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) {
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()
}
}
@ -217,8 +344,19 @@ class DownloaderImpl(
val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
for (mediaStream in source.mediaStreams.filter { it.isExternal }) {
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 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(mediaStream.path))
.setTitle(mediaStream.title)
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
@ -235,7 +373,10 @@ class DownloaderImpl(
sourceId: String,
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>()
for (i in 0..maxIndex) {
jellyfinRepository.getTrickplayData(
@ -263,4 +404,52 @@ 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 // 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>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,17 @@
android:defaultValue="false"
app:key="pref_downloads_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>

View file

@ -11,9 +11,14 @@ import com.nomadics9.ananas.models.FindroidShow
import com.nomadics9.ananas.models.FindroidSource
import com.nomadics9.ananas.models.SortBy
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.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.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserConfiguration
@ -113,5 +118,15 @@ interface JellyfinRepository {
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.flow.Flow
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.hlsSegmentApi
import org.jellyfin.sdk.model.api.BaseItemDto
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.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.GeneralCommandType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemFilter
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.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.SortOrder
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 org.jellyfin.sdk.model.api.UserConfiguration
import timber.log.Timber
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) {
1080 -> 8000000 to 384000 // Adjusted for 1080p
720 -> 2000000 to 384000 // Adjusted for 720p
480 -> 1000000 to 384000 // Adjusted for 480p
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.flow.Flow
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto
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.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserConfiguration
@ -286,7 +290,42 @@ class JellyfinRepositoryOfflineImpl(
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")
}
}

View file

@ -67,7 +67,6 @@ class PlayerActivityViewModel
constructor(
private val application: Application,
private val jellyfinRepository: JellyfinRepository,
private val jellyfinApi: JellyfinApi,
private val appPreferences: AppPreferences,
private val savedStateHandle: SavedStateHandle,
) : ViewModel(), Player.Listener {
@ -547,77 +546,11 @@ constructor(
val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
transcodingResolution
)
val deviceProfile = ClientCapabilitiesDto(
supportedCommands = emptyList(),
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 deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "ts", EncodingContext.STREAMING)
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,true,deviceProfile,videoBitRate)
val playSessionId = playbackInfo.content.playSessionId
val getDeviceId =
jellyfinApi.devicesApi.getDeviceOptions(jellyfinApi.userId.toString())
val deviceId = getDeviceId.content.deviceId
if (playSessionId != null) {
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
deviceId,
playSessionId
)
jellyfinRepository.stopEncodingProcess(playSessionId)
}
val mediaSource = playbackInfo.content.mediaSources.firstOrNull()
if (mediaSource == null) {
@ -632,8 +565,8 @@ constructor(
.setMimeType(externalSubtitle.mimeType)
.build()
}
// Timber.tag("MediaStreams").d("Media Streams: %s", mediaSource?.mediaStreams)
//TODO: Embedded sub support
// TODO: Embedded sub support
// val embeddedSubtitles = mediaSource?.mediaStreams
// ?.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal }
// ?.map { mediaStream ->
@ -651,18 +584,11 @@ constructor(
// .build()
// }
// ?.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 staticUrl = jellyfinApi.videosApi.getVideoStreamUrl(
itemId,
static = true,
playSessionId = playSessionId,
deviceId = deviceId,
mediaSourceId = currentItem.mediaSourceId,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL
)
val baseUrl = jellyfinRepository.getBaseUrl()
val cleanBaseUrl = baseUrl.removePrefix("http://").removePrefix("https://")
val staticUrl = jellyfinRepository.getStreamUrl(itemId, currentItem.mediaSourceId)
val uri =
@ -719,6 +645,7 @@ constructor(
uriBuilder.setOrReplaceQueryParameter("VideoBitrate", videoBitRate.toString())
uriBuilder.setOrReplaceQueryParameter("AudioBitrate", audioBitRate.toString())
uriBuilder.setOrReplaceQueryParameter("Static", "false")
uriBuilder.appendQueryParameter("PlaySessionId", playSessionId)
uriBuilder.appendQueryParameter(
"MaxVideoHeight",
transcodingResolution.toString()

View file

@ -141,6 +141,18 @@ 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

@ -45,6 +45,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"