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 exporting single playlists #6855

Merged
merged 2 commits into from
Dec 5, 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
33 changes: 3 additions & 30 deletions app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ import com.github.libretube.extensions.parallelMap
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.obj.FreeTubeImportPlaylist
import com.github.libretube.obj.FreeTubeVideo
import com.github.libretube.obj.PipedImportPlaylist
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
Expand Down Expand Up @@ -201,35 +198,11 @@ object PlaylistsHelper {
}
}

suspend fun exportPipedPlaylists(): List<PipedImportPlaylist> = withContext(Dispatchers.IO) {
getPlaylists()
.map { async { getPlaylist(it.id!!) } }
.awaitAll()
.map {
val videos = it.relatedStreams.map { item ->
"$YOUTUBE_FRONTEND_URL/watch?v=${item.url!!.toID()}"
}
PipedImportPlaylist(it.name, "playlist", "private", videos)
}
}

suspend fun exportFreeTubePlaylists(): List<FreeTubeImportPlaylist> =
suspend fun getAllPlaylistsWithVideos(playlistIds: List<String>? = null): List<Playlist> =
withContext(Dispatchers.IO) {
getPlaylists()
.map { async { getPlaylist(it.id!!) } }
(playlistIds ?: getPlaylists().map { it.id!! })
.map { async { getPlaylist(it) } }
.awaitAll()
.map { playlist ->
val videos = playlist.relatedStreams.map { videoInfo ->
FreeTubeVideo(
videoId = videoInfo.url.orEmpty().toID(),
title = videoInfo.title.orEmpty(),
author = videoInfo.uploaderName.orEmpty(),
authorId = videoInfo.uploaderUrl.orEmpty().toID(),
lengthSeconds = videoInfo.duration ?: 0L
)
}
FreeTubeImportPlaylist(playlist.name.orEmpty(), videos)
}
}

suspend fun clonePlaylist(playlistId: String): String? {
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/github/libretube/enums/ImportFormat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.github.libretube.enums

import androidx.annotation.StringRes
import com.github.libretube.R

enum class ImportFormat(@StringRes val value: Int, val fileExtension: String) {
NEWPIPE(R.string.import_format_newpipe, "json"),
FREETUBE(R.string.import_format_freetube, "json"),
YOUTUBECSV(R.string.import_format_youtube_csv, "csv"),
YOUTUBEJSON(R.string.youtube, "json"),
PIPED(R.string.import_format_piped, "json"),
URLSORIDS(R.string.import_format_list_of_urls, "txt")
}
13 changes: 0 additions & 13 deletions app/src/main/java/com/github/libretube/enums/SupportedClient.kt

This file was deleted.

66 changes: 51 additions & 15 deletions app/src/main/java/com/github/libretube/helpers/ImportHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,19 @@ import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.enums.ImportFormat
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.obj.FreeTubeImportPlaylist
import com.github.libretube.obj.FreeTubeVideo
import com.github.libretube.obj.FreetubeSubscription
import com.github.libretube.obj.FreetubeSubscriptions
import com.github.libretube.obj.NewPipeSubscription
import com.github.libretube.obj.NewPipeSubscriptions
import com.github.libretube.obj.PipedImportPlaylist
import com.github.libretube.obj.PipedPlaylistFile
import com.github.libretube.obj.YouTubeWatchHistoryFileItem
import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_SHORT_URL
import com.github.libretube.util.TextUtils
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
Expand All @@ -34,6 +37,7 @@ import java.util.stream.Collectors

object ImportHelper {
private const val IMPORT_THUMBNAIL_QUALITY = "mqdefault"
private const val VIDEO_ID_LENGTH = 11

/**
* Import subscriptions by a file uri
Expand Down Expand Up @@ -70,7 +74,7 @@ object ImportHelper {
JsonHelper.json.decodeFromStream<NewPipeSubscriptions>(it)
}
subscriptions?.subscriptions.orEmpty().map {
it.url.replace("${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/", "")
it.url.replace("$YOUTUBE_FRONTEND_URL/channel/", "")
}
}

Expand All @@ -79,7 +83,7 @@ object ImportHelper {
JsonHelper.json.decodeFromStream<FreetubeSubscriptions>(it)
}
subscriptions?.subscriptions.orEmpty().map {
it.url.replace("${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/", "")
it.url.replace("$YOUTUBE_FRONTEND_URL/channel/", "")
}
}

Expand Down Expand Up @@ -114,7 +118,7 @@ object ImportHelper {
when (importFormat) {
ImportFormat.NEWPIPE -> {
val newPipeChannels = subs.map {
NewPipeSubscription(it.name, 0, "${ShareDialog.YOUTUBE_FRONTEND_URL}${it.url}")
NewPipeSubscription(it.name, 0, "$YOUTUBE_FRONTEND_URL${it.url}")
}
val newPipeSubscriptions = NewPipeSubscriptions(subscriptions = newPipeChannels)
activity.contentResolver.openOutputStream(uri)?.use {
Expand All @@ -127,7 +131,7 @@ object ImportHelper {
FreetubeSubscription(
it.name,
"",
"${ShareDialog.YOUTUBE_FRONTEND_URL}${it.url}"
"$YOUTUBE_FRONTEND_URL${it.url}"
)
}
val freeTubeSubscriptions = FreetubeSubscriptions(subscriptions = freeTubeChannels)
Expand Down Expand Up @@ -158,7 +162,7 @@ object ImportHelper {

// convert the YouTube URLs to videoIds
importPlaylists.forEach { playlist ->
playlist.videos = playlist.videos.map { it.takeLast(11) }
playlist.videos = playlist.videos.map { it.takeLast(VIDEO_ID_LENGTH) }
}
}

Expand Down Expand Up @@ -225,7 +229,7 @@ object ImportHelper {

// convert the YouTube URLs to videoIds
importPlaylists.forEach { importPlaylist ->
importPlaylist.videos = importPlaylist.videos.map { it.takeLast(11) }
importPlaylist.videos = importPlaylist.videos.map { it.takeLast(VIDEO_ID_LENGTH) }
}
}

Expand All @@ -236,7 +240,7 @@ object ImportHelper {
playlist.videos = inputStream.bufferedReader().readLines()
.flatMap { it.split(",") }
.mapNotNull { videoUrlOrId ->
if (videoUrlOrId.length == 11) {
if (videoUrlOrId.length == VIDEO_ID_LENGTH) {
videoUrlOrId
} else {
TextUtils.getVideoIdFromUri(videoUrlOrId.toUri())
Expand Down Expand Up @@ -272,11 +276,22 @@ object ImportHelper {
* Export Playlists
*/
@OptIn(ExperimentalSerializationApi::class)
suspend fun exportPlaylists(activity: Activity, uri: Uri, importFormat: ImportFormat) {
suspend fun exportPlaylists(
activity: Activity,
uri: Uri,
importFormat: ImportFormat,
selectedPlaylistIds: List<String>? = null
) {
val playlists = PlaylistsHelper.getAllPlaylistsWithVideos(selectedPlaylistIds)

when (importFormat) {
ImportFormat.PIPED -> {
val playlists = PlaylistsHelper.exportPipedPlaylists()
val playlistFile = PipedPlaylistFile(playlists = playlists)
val playlistFile = PipedPlaylistFile(playlists = playlists.map {
val videos = it.relatedStreams.map { item ->
"$YOUTUBE_FRONTEND_URL/watch?v=${item.url!!.toID()}"
}
PipedImportPlaylist(it.name, "playlist", "private", videos)
})

activity.contentResolver.openOutputStream(uri)?.use {
JsonHelper.json.encodeToStream(playlistFile, it)
Expand All @@ -285,17 +300,38 @@ object ImportHelper {
}

ImportFormat.FREETUBE -> {
val playlists = PlaylistsHelper.exportFreeTubePlaylists()

val freeTubeExportDb = playlists.joinToString("\n") { playlist ->
val freeTubeExportDb = playlists.map { playlist ->
val videos = playlist.relatedStreams.map { videoInfo ->
FreeTubeVideo(
videoId = videoInfo.url.orEmpty().toID(),
title = videoInfo.title.orEmpty(),
author = videoInfo.uploaderName.orEmpty(),
authorId = videoInfo.uploaderUrl.orEmpty().toID(),
lengthSeconds = videoInfo.duration ?: 0L
)
}
FreeTubeImportPlaylist(playlist.name.orEmpty(), videos)
}.joinToString("\n") { playlist ->
JsonHelper.json.encodeToString(playlist)
}

activity.contentResolver.openOutputStream(uri)?.use {
it.write(freeTubeExportDb.toByteArray())
}
activity.toastFromMainDispatcher(R.string.exportsuccess)
}

ImportFormat.URLSORIDS -> {
val urlListExport = playlists
.flatMap { it.relatedStreams }
.joinToString("\n") { YOUTUBE_SHORT_URL + "/watch?v=" + it.url!!.toID() }

activity.contentResolver.openOutputStream(uri)?.use {
it.write(urlListExport.toByteArray())
}
activity.toastFromMainDispatcher(R.string.exportsuccess)
}

else -> Unit
}
}
Expand All @@ -311,7 +347,7 @@ object ImportHelper {
.filter { it.activityControls.contains("YouTube watch history") && it.subtitles.isNotEmpty() && it.titleUrl.isNotEmpty() }
.reversed()
.map {
val videoId = it.titleUrl.substring(it.titleUrl.length - 11)
val videoId = it.titleUrl.takeLast(VIDEO_ID_LENGTH)

WatchHistoryItem(
videoId = videoId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import android.view.View
import android.view.ViewTreeObserver
import android.widget.ScrollView
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.ColorInt
import androidx.appcompat.widget.SearchView
Expand All @@ -36,8 +37,10 @@ import com.github.libretube.compat.PictureInPictureCompat
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.ActivityMainBinding
import com.github.libretube.enums.ImportFormat
import com.github.libretube.extensions.anyChildFocused
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.ImportHelper
import com.github.libretube.helpers.IntentHelper
import com.github.libretube.helpers.NavBarHelper
import com.github.libretube.helpers.NavigationHelper
Expand All @@ -54,11 +57,14 @@ import com.github.libretube.ui.fragments.PlayerFragment
import com.github.libretube.ui.models.CommonPlayerViewModel
import com.github.libretube.ui.models.SearchViewModel
import com.github.libretube.ui.models.SubscriptionsViewModel
import com.github.libretube.ui.preferences.BackupRestoreSettings.Companion.FILETYPE_ANY
import com.github.libretube.ui.preferences.BackupRestoreSettings.Companion.JSON
import com.github.libretube.util.UpdateChecker
import com.google.android.material.elevation.SurfaceColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import kotlin.math.exp

class MainActivity : BaseActivity() {
lateinit var binding: ActivityMainBinding
Expand All @@ -75,6 +81,25 @@ class MainActivity : BaseActivity() {
private var savedSearchQuery: String? = null
private var shouldOpenSuggestions = true

// registering for activity results is only possible, this here should have been part of
// PlaylistOptionsBottomSheet instead if Android allowed us to
private var playlistExportFormat: ImportFormat = ImportFormat.NEWPIPE
private var exportPlaylistId: String? = null
private val createPlaylistsFile = registerForActivityResult(
ActivityResultContracts.CreateDocument(FILETYPE_ANY)
) { uri ->
if (uri == null) return@registerForActivityResult

lifecycleScope.launch(Dispatchers.IO) {
ImportHelper.exportPlaylists(
this@MainActivity,
uri,
playlistExportFormat,
selectedPlaylistIds = listOf(exportPlaylistId!!)
)
}
}

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

Expand Down Expand Up @@ -652,4 +677,10 @@ class MainActivity : BaseActivity() {
?.let(action)
?: false
}

fun startPlaylistExport(playlistId: String, playlistName: String, format: ImportFormat) {
playlistExportFormat = format
exportPlaylistId = playlistId
createPlaylistsFile.launch("${playlistName}.${format.fileExtension}")
}
}
Loading
Loading