Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for downloading whole playlist at once #5525

Merged
merged 1 commit into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,12 @@
android:enabled="true"
android:exported="false" />

<service
android:name=".services.PlaylistDownloadEnqueueService"
android:foregroundServiceType="dataSync"
android:enabled="true"
android:exported="false" />

<service
android:name=".services.OnlinePlayerService"
android:foregroundServiceType="mediaPlayback"
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/github/libretube/LibreTubeApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@
*/
private fun initializeNotificationChannels() {
val downloadChannel = NotificationChannelCompat.Builder(
PLAYLIST_DOWNLOAD_ENQUEUE_CHANNEL_NAME,
NotificationManagerCompat.IMPORTANCE_LOW

Check failure on line 66 in app/src/main/java/com/github/libretube/LibreTubeApp.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 Missing trailing comma before ")" Raw Output: app/src/main/java/com/github/libretube/LibreTubeApp.kt:66:53: error: Missing trailing comma before ")" (standard:trailing-comma-on-call-site)
)
.setName(getString(R.string.download_playlist))
.setDescription(getString(R.string.enqueue_playlist_description))
.build()
val playlistDownloadEnqueueChannel = NotificationChannelCompat.Builder(

Check failure on line 71 in app/src/main/java/com/github/libretube/LibreTubeApp.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 A multiline expression should start on a new line Raw Output: app/src/main/java/com/github/libretube/LibreTubeApp.kt:71:46: error: A multiline expression should start on a new line (standard:multiline-expression-wrapping)
DOWNLOAD_CHANNEL_NAME,
NotificationManagerCompat.IMPORTANCE_LOW
)
Expand All @@ -87,6 +94,7 @@
notificationManager.createNotificationChannelsCompat(
listOf(
downloadChannel,
playlistDownloadEnqueueChannel,
pushChannel,
playerChannel
)
Expand All @@ -97,6 +105,7 @@
lateinit var instance: LibreTubeApp

const val DOWNLOAD_CHANNEL_NAME = "download_service"
const val PLAYLIST_DOWNLOAD_ENQUEUE_CHANNEL_NAME = "playlist_download_enqueue"
const val PLAYER_CHANNEL_NAME = "player_mode"
const val PUSH_CHANNEL_NAME = "notification_worker"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@
const val isSubscribed = "isSubscribed"
const val sortOptions = "sortOptions"
const val hideWatched = "hideWatched"
const val maxVideoQuality = "maxVideoQuality"

Check failure on line 39 in app/src/main/java/com/github/libretube/constants/IntentData.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 Property name should use the screaming snake case notation when the value can not be changed Raw Output: app/src/main/java/com/github/libretube/constants/IntentData.kt:39:15: error: Property name should use the screaming snake case notation when the value can not be changed (standard:property-naming)
const val maxAudioQuality = "maxAudioQuality"

Check failure on line 40 in app/src/main/java/com/github/libretube/constants/IntentData.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 Property name should use the screaming snake case notation when the value can not be changed Raw Output: app/src/main/java/com/github/libretube/constants/IntentData.kt:40:15: error: Property name should use the screaming snake case notation when the value can not be changed (standard:property-naming)
const val audioLanguage = "audioLanguage"

Check failure on line 41 in app/src/main/java/com/github/libretube/constants/IntentData.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 Property name should use the screaming snake case notation when the value can not be changed Raw Output: app/src/main/java/com/github/libretube/constants/IntentData.kt:41:15: error: Property name should use the screaming snake case notation when the value can not be changed (standard:property-naming)
const val captionLanguage = "captionLanguage"

Check failure on line 42 in app/src/main/java/com/github/libretube/constants/IntentData.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 Property name should use the screaming snake case notation when the value can not be changed Raw Output: app/src/main/java/com/github/libretube/constants/IntentData.kt:42:15: error: Property name should use the screaming snake case notation when the value can not be changed (standard:property-naming)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package com.github.libretube.services

import android.app.Notification
import android.app.NotificationManager
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.github.libretube.LibreTubeApp.Companion.PLAYLIST_DOWNLOAD_ENQUEUE_CHANNEL_NAME
import com.github.libretube.R
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.PipedStream
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.IntentData
import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.getWhileDigit
import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.DownloadHelper
import com.github.libretube.parcelable.DownloadData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

Check failure on line 27 in app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 Unused import Raw Output: app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt:27:1: error: Unused import (standard:no-unused-imports)

class PlaylistDownloadEnqueueService : LifecycleService() {
private lateinit var nManager: NotificationManager

private lateinit var playlistId: String
private lateinit var playlistType: PlaylistType
private var playlistName: String? = null
private var maxVideoQuality: Int? = null
private var maxAudioQuality: Int? = null
private var audioLanguage: String? = null
private var captionLanguage: String? = null
private var amountOfVideos = 0
private var amountOfVideosDone = 0

override fun onCreate() {
super.onCreate()

startForeground(ENQUEUE_PROGRESS_NOTIFICATION_ID, buildNotification())
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {

Check failure on line 48 in app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 Newline expected after opening parenthesis Raw Output: app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt:48:33: error: Newline expected after opening parenthesis (standard:function-signature)

Check failure on line 48 in app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt

View workflow job for this annotation

GitHub Actions / Check Code Quality

[ktlint] reported by reviewdog 🐶 Parameter should start on a newline Raw Output: app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt:48:50: error: Parameter should start on a newline (standard:function-signature)
nManager = getSystemService()!!

playlistId = intent!!.getStringExtra(IntentData.playlistId)!!
playlistName = intent.getStringExtra(IntentData.playlistName)!!
playlistType = intent.serializableExtra(IntentData.playlistType)!!
maxVideoQuality = intent.getIntExtra(IntentData.maxVideoQuality, 0).takeIf { it != 0 }
maxAudioQuality = intent.getIntExtra(IntentData.maxAudioQuality, 0).takeIf { it != 0 }
captionLanguage = intent.getStringExtra(IntentData.captionLanguage)
audioLanguage = intent.getStringExtra(IntentData.audioLanguage)

nManager.notify(ENQUEUE_PROGRESS_NOTIFICATION_ID, buildNotification())

lifecycleScope.launch(Dispatchers.IO) {
if (playlistType != PlaylistType.PUBLIC) {
enqueuePrivatePlaylist()
} else {
enqueuePublicPlaylist()
}
}

return super.onStartCommand(intent, flags, startId)
}

private fun buildNotification(): Notification {
return NotificationCompat.Builder(this, PLAYLIST_DOWNLOAD_ENQUEUE_CHANNEL_NAME)
.setSmallIcon(R.drawable.ic_download)
.setContentTitle(getString(R.string.enqueueing_playlist_download, playlistName ?: "..."))
.setProgress(amountOfVideos, amountOfVideosDone, false)
.setOnlyAlertOnce(true)
.build()
}

private suspend fun enqueuePrivatePlaylist() {
val playlist = try {
PlaylistsHelper.getPlaylist(playlistId)
} catch (e: Exception) {
toastFromMainDispatcher(e.localizedMessage)
stopSelf()
return
}
amountOfVideos = playlist.videos
enqueueStreams(playlist.relatedStreams)
}

private suspend fun enqueuePublicPlaylist() {
val playlist = try {
RetrofitInstance.api.getPlaylist(playlistId)
} catch (e: Exception) {
toastFromMainDispatcher(e.localizedMessage)
stopSelf()
return
}

amountOfVideos = playlist.videos
enqueueStreams(playlist.relatedStreams)

var nextPage = playlist.nextpage
// retry each api call once when fetching next pages to increase success chances
var alreadyRetriedOnce = false

while (nextPage != null) {
val playlistPage = runCatching {
RetrofitInstance.api.getPlaylistNextPage(playlistId, nextPage!!)
}.getOrNull()

if (playlistPage == null && alreadyRetriedOnce) {
toastFromMainDispatcher(R.string.unknown_error)
stopSelf()
return
}

if (playlistPage == null) {
// retry if previous attempt failed
alreadyRetriedOnce = true
continue
}

alreadyRetriedOnce = false
enqueueStreams(playlistPage.relatedStreams)
nextPage = playlistPage.nextpage
}
}

private suspend fun enqueueStreams(streams: List<StreamItem>) {
nManager.notify(ENQUEUE_PROGRESS_NOTIFICATION_ID, buildNotification())

for (stream in streams) {
val videoInfo = runCatching {
RetrofitInstance.api.getStreams(stream.url!!.toID())
}.getOrNull() ?: continue

val videoStream = getStream(videoInfo.videoStreams, maxVideoQuality)
val audioStream = getStream(videoInfo.audioStreams, maxAudioQuality)

val downloadData = DownloadData(
videoId = stream.url!!.toID(),
fileName = videoInfo.title,
videoFormat = videoStream?.format,
videoQuality = videoStream?.quality,
audioFormat = audioStream?.format,
audioQuality = audioStream?.quality,
audioLanguage = audioLanguage.takeIf {
videoInfo.audioStreams.any { it.audioTrackLocale == audioLanguage }
},
subtitleCode = captionLanguage.takeIf {
videoInfo.subtitles.any { it.code == captionLanguage }
}
)
DownloadHelper.startDownloadService(this, downloadData)

amountOfVideosDone++
nManager.notify(ENQUEUE_PROGRESS_NOTIFICATION_ID, buildNotification())
}

if (amountOfVideos == amountOfVideosDone) stopSelf()
}

private fun getStream(streams: List<PipedStream>, maxQuality: Int?): PipedStream? {
val maxStreamQuality = maxQuality ?: return null

// sort streams by their quality/bitrate
val sortedStreams = streams
.sortedBy { it.quality.getWhileDigit() }

// return the last item below the maximum quality - or if there's none - the stream with
// the lowest quality available
return sortedStreams
.lastOrNull { it.quality.getWhileDigit()!! <= maxStreamQuality }
?: sortedStreams.firstOrNull()
Bnyro marked this conversation as resolved.
Show resolved Hide resolved
}

override fun onDestroy() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()

super.onDestroy()
Bnyro marked this conversation as resolved.
Show resolved Hide resolved
}

companion object {
private const val ENQUEUE_PROGRESS_NOTIFICATION_ID = 3
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than having this here as a magic number, would it make sense to create an enum somewhere else along with other notification IDs? It's difficult to understand why 3 is used otherwise. (I'm guessing 1 and 2 are used elsewhere in the app)

This could be another PR perhaps.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should probably do thay in an other PR. 1 and 2 are used for the player and the normal download notification.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.github.libretube.ui.dialogs

import android.app.Dialog
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import com.github.libretube.R
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.DialogDownloadPlaylistBinding
import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.getWhileDigit
import com.github.libretube.extensions.serializable
import com.github.libretube.helpers.LocaleHelper
import com.github.libretube.services.PlaylistDownloadEnqueueService
import com.google.android.material.dialog.MaterialAlertDialogBuilder

class DownloadPlaylistDialog : DialogFragment() {
private lateinit var playlistId: String
private lateinit var playlistName: String
private lateinit var playlistType: PlaylistType

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

playlistId = requireArguments().getString(IntentData.playlistId)!!
playlistName = requireArguments().getString(IntentData.playlistName)!!
playlistType = requireArguments().serializable(IntentData.playlistType)!!
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = DialogDownloadPlaylistBinding.inflate(layoutInflater)

val possibleVideoQualities = resources.getStringArray(R.array.defres).toList().let {
// remove the automatic quality entry
it.subList(1, it.size)
}
val possibleAudioQualities = resources.getStringArray(R.array.audioQualityBitrates)
val availableLanguages = LocaleHelper.getAvailableLocales()

binding.videoSpinner.items = listOf(getString(R.string.no_video)) + possibleVideoQualities
binding.audioSpinner.items = listOf(getString(R.string.no_audio)) + possibleAudioQualities
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wanted to bring up the debate on having different default values. Additionally, do you think it may be worth saving on shared prefs the previously used settings in the future? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, in the future. We already do this for the normal download dialog, but I wanted to keep this PR simple and basic for now.

binding.subtitleSpinner.items =
listOf(getString(R.string.no_subtitle)) + availableLanguages.map { it.name }
binding.audioLanguageSpinner.items =
listOf(getString(R.string.default_language)) + availableLanguages.map { it.name }

return MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.download_playlist) + ": " + playlistName)
.setMessage(R.string.download_playlist_note)
.setView(binding.root)
.setPositiveButton(R.string.download) { _, _ ->
with(binding) {
val maxVideoQuality = if (videoSpinner.selectedItemPosition >= 1)
possibleVideoQualities[videoSpinner.selectedItemPosition - 1]
.getWhileDigit() else null

val maxAudioQuality = if (audioSpinner.selectedItemPosition >= 1)
possibleAudioQualities[audioSpinner.selectedItemPosition - 1]
.getWhileDigit() else null

val captionLanguage = if (subtitleSpinner.selectedItemPosition >= 1)
availableLanguages[subtitleSpinner.selectedItemPosition - 1].code
else null

val audioLanguage = if (audioLanguageSpinner.selectedItemPosition >= 1)
availableLanguages[audioLanguageSpinner.selectedItemPosition - 1].code
else null

if (maxVideoQuality == null && maxAudioQuality == null) {
Toast.makeText(context, R.string.nothing_selected, Toast.LENGTH_SHORT)
.show()
return@setPositiveButton
}

val downloadEnqueueIntent =
Intent(requireContext(), PlaylistDownloadEnqueueService::class.java)
.putExtra(IntentData.playlistId, playlistId)
.putExtra(IntentData.playlistType, playlistType)
.putExtra(IntentData.playlistName, playlistName)
.putExtra(IntentData.audioLanguage, audioLanguage)
.putExtra(IntentData.maxVideoQuality, maxVideoQuality)
.putExtra(IntentData.maxAudioQuality, maxAudioQuality)
.putExtra(IntentData.captionLanguage, captionLanguage)

ContextCompat.startForegroundService(requireContext(), downloadEnqueueIntent)
}
}
.setNegativeButton(R.string.cancel, null)
.show()

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.github.libretube.helpers.BackgroundHelper
import com.github.libretube.obj.ShareData
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.DeletePlaylistDialog
import com.github.libretube.ui.dialogs.DownloadPlaylistDialog
import com.github.libretube.ui.dialogs.PlaylistDescriptionDialog
import com.github.libretube.ui.dialogs.RenamePlaylistDialog
import com.github.libretube.ui.dialogs.ShareDialog
Expand All @@ -39,7 +40,7 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
setTitle(playlistName)

// options for the dialog
val optionsList = mutableListOf(R.string.playOnBackground)
val optionsList = mutableListOf(R.string.playOnBackground, R.string.download)

if (PlayingQueue.isNotEmpty()) optionsList.add(R.string.add_to_queue)

Expand Down Expand Up @@ -137,6 +138,17 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
newPlaylistDescriptionDialog.show(mFragmentManager, null)
}

R.string.download -> {
val downloadPlaylistDialog = DownloadPlaylistDialog().apply {
arguments = bundleOf(
IntentData.playlistId to playlistId,
IntentData.playlistName to playlistName,
IntentData.playlistType to playlistType
)
}
downloadPlaylistDialog.show(mFragmentManager, null)
}

else -> {
withContext(Dispatchers.IO) {
if (isBookmarked) {
Expand Down
Loading
Loading