support freetube playlists import/export (#3821)

* support freetube playlists import/export
---------

Co-authored-by: karen <karen@host.com>
This commit is contained in:
yoguut 2023-05-25 08:23:48 -07:00 committed by GitHub
parent d4ed656587
commit eef9437326
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 144 additions and 52 deletions

View File

@ -12,7 +12,9 @@ import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.obj.ImportPlaylist import com.github.libretube.obj.FreeTubeImportPlaylist
import com.github.libretube.obj.FreeTubeVideo
import com.github.libretube.obj.PipedImportPlaylist
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -133,7 +135,7 @@ object PlaylistsHelper {
} }
} }
suspend fun importPlaylists(playlists: List<ImportPlaylist>) = withContext(Dispatchers.IO) { suspend fun importPlaylists(playlists: List<PipedImportPlaylist>) = withContext(Dispatchers.IO) {
playlists.map { playlist -> playlists.map { playlist ->
val playlistId = createPlaylist(playlist.name!!) val playlistId = createPlaylist(playlist.name!!)
async { async {
@ -167,7 +169,7 @@ object PlaylistsHelper {
}.awaitAll() }.awaitAll()
} }
suspend fun exportPlaylists(): List<ImportPlaylist> = withContext(Dispatchers.IO) { suspend fun exportPipedPlaylists(): List<PipedImportPlaylist> = withContext(Dispatchers.IO) {
getPlaylists() getPlaylists()
.map { async { getPlaylist(it.id!!) } } .map { async { getPlaylist(it.id!!) } }
.awaitAll() .awaitAll()
@ -175,10 +177,25 @@ object PlaylistsHelper {
val videos = it.relatedStreams.map { item -> val videos = it.relatedStreams.map { item ->
"$YOUTUBE_FRONTEND_URL/watch?v=${item.url!!.toID()}" "$YOUTUBE_FRONTEND_URL/watch?v=${item.url!!.toID()}"
} }
ImportPlaylist(it.name, "playlist", "private", videos) PipedImportPlaylist(it.name, "playlist", "private", videos)
} }
} }
suspend fun exportFreeTubePlaylists(): List<FreeTubeImportPlaylist> =
withContext(Dispatchers.IO) {
getPlaylists()
.map { async { getPlaylist(it.id!!) } }
.awaitAll()
.map {
val videos = it.relatedStreams.map { item ->
item.url.orEmpty().replace("$YOUTUBE_FRONTEND_URL/watch?v=${item.url}", "")
}.map { id ->
FreeTubeVideo(id, it.name.orEmpty(), "", "")
}
FreeTubeImportPlaylist(it.name.orEmpty(), videos)
}
}
suspend fun clonePlaylist(playlistId: String): String? { suspend fun clonePlaylist(playlistId: String): String? {
if (!loggedIn) { if (!loggedIn) {
val playlist = RetrofitInstance.api.getPlaylist(playlistId) val playlist = RetrofitInstance.api.getPlaylist(playlistId)

View File

@ -6,5 +6,6 @@ import com.github.libretube.R
enum class ImportFormat(@StringRes val value: Int) { enum class ImportFormat(@StringRes val value: Int) {
NEWPIPE(R.string.import_format_newpipe), NEWPIPE(R.string.import_format_newpipe),
FREETUBE(R.string.import_format_freetube), FREETUBE(R.string.import_format_freetube),
YOUTUBECSV(R.string.import_format_youtube_csv); YOUTUBECSV(R.string.import_format_youtube_csv),
PIPED(R.string.import_format_piped);
} }

View File

@ -12,10 +12,11 @@ import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.enums.ImportFormat import com.github.libretube.enums.ImportFormat
import com.github.libretube.extensions.TAG import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.obj.FreeTubeImportPlaylist
import com.github.libretube.obj.FreetubeSubscription import com.github.libretube.obj.FreetubeSubscription
import com.github.libretube.obj.FreetubeSubscriptions import com.github.libretube.obj.FreetubeSubscriptions
import com.github.libretube.obj.ImportPlaylist import com.github.libretube.obj.PipedImportPlaylist
import com.github.libretube.obj.ImportPlaylistFile import com.github.libretube.obj.PipedImportPlaylistFile
import com.github.libretube.obj.NewPipeSubscription import com.github.libretube.obj.NewPipeSubscription
import com.github.libretube.obj.NewPipeSubscriptions import com.github.libretube.obj.NewPipeSubscriptions
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@ -76,6 +77,7 @@ object ImportHelper {
} }
}.orEmpty() }.orEmpty()
} }
else -> throw IllegalArgumentException()
} }
} }
@ -123,12 +125,38 @@ object ImportHelper {
* Import Playlists * Import Playlists
*/ */
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun importPlaylists(activity: Activity, uri: Uri) { suspend fun importPlaylists(activity: Activity, uri: Uri, importFormat: ImportFormat) {
val importPlaylists = mutableListOf<ImportPlaylist>() val importPlaylists = mutableListOf<PipedImportPlaylist>()
when (val fileType = activity.contentResolver.getType(uri)) { when (importFormat) {
"text/csv", "text/comma-separated-values" -> { ImportFormat.PIPED -> {
val playlist = ImportPlaylist() val playlistFile = activity.contentResolver.openInputStream(uri)?.use {
JsonHelper.json.decodeFromStream<PipedImportPlaylistFile>(it)
}
importPlaylists.addAll(playlistFile?.playlists.orEmpty())
// convert the YouTube URLs to videoIds
importPlaylists.forEach { playlist ->
playlist.videos = playlist.videos.map { it.takeLast(11) }
}
}
ImportFormat.FREETUBE -> {
val playlistFile = activity.contentResolver.openInputStream(uri)?.use {
JsonHelper.json.decodeFromStream<List<FreeTubeImportPlaylist>>(it)
}
val playlists = playlistFile?.map { playlist ->
// convert FreeTube videos to list of string
// convert FreeTube playlists to piped playlists
PipedImportPlaylist(
playlist.name,
null,
null,
playlist.videos.map { it.videoId })
}
importPlaylists.addAll(playlists.orEmpty())
}
ImportFormat.YOUTUBECSV -> {
val playlist = PipedImportPlaylist()
activity.contentResolver.openInputStream(uri)?.use { activity.contentResolver.openInputStream(uri)?.use {
val lines = it.bufferedReader().use { reader -> reader.lines().toList() } val lines = it.bufferedReader().use { reader -> reader.lines().toList() }
playlist.name = lines[1].split(",").reversed()[2] playlist.name = lines[1].split(",").reversed()[2]
@ -144,23 +172,13 @@ object ImportHelper {
} }
importPlaylists.add(playlist) importPlaylists.add(playlist)
} }
}
"application/json", "application/*", "application/octet-stream" -> {
val playlistFile = activity.contentResolver.openInputStream(uri)?.use {
JsonHelper.json.decodeFromStream<ImportPlaylistFile>(it)
}
importPlaylists.addAll(playlistFile?.playlists.orEmpty())
}
else -> {
val message = activity.getString(R.string.unsupported_file_format, fileType)
activity.toastFromMainDispatcher(message)
return
}
}
// convert the YouTube URLs to videoIds // convert the YouTube URLs to videoIds
importPlaylists.forEach { playlist -> importPlaylists.forEach { importPlaylist ->
playlist.videos = playlist.videos.map { it.takeLast(11) } importPlaylist.videos = importPlaylist.videos.map { it.takeLast(11) }
}
}
else -> throw IllegalArgumentException()
} }
try { try {
PlaylistsHelper.importPlaylists(importPlaylists) PlaylistsHelper.importPlaylists(importPlaylists)
@ -177,14 +195,26 @@ object ImportHelper {
* Export Playlists * Export Playlists
*/ */
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun exportPlaylists(activity: Activity, uri: Uri) { suspend fun exportPlaylists(activity: Activity, uri: Uri, importFormat: ImportFormat) {
val playlists = PlaylistsHelper.exportPlaylists() when (importFormat) {
val playlistFile = ImportPlaylistFile("Piped", 1, playlists) ImportFormat.PIPED -> {
val playlists = PlaylistsHelper.exportPipedPlaylists()
val playlistFile = PipedImportPlaylistFile("Piped", 1, playlists)
activity.contentResolver.openOutputStream(uri)?.use { activity.contentResolver.openOutputStream(uri)?.use {
JsonHelper.json.encodeToStream(playlistFile, it) JsonHelper.json.encodeToStream(playlistFile, it)
}
activity.toastFromMainDispatcher(R.string.exportsuccess)
}
ImportFormat.FREETUBE -> {
val playlists = PlaylistsHelper.exportFreeTubePlaylists()
activity.contentResolver.openOutputStream(uri)?.use {
JsonHelper.json.encodeToStream(playlists, it)
}
activity.toastFromMainDispatcher(R.string.exportsuccess)
}
else -> Unit
} }
activity.toastFromMainDispatcher(R.string.exportsuccess)
} }
} }

View File

@ -0,0 +1,11 @@
package com.github.libretube.obj
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class FreeTubeImportPlaylist(
@SerialName("playlistName") val name: String = "",
// if type is `video` -> https://www.youtube.com/watch?v=IT734HriiHQ, works with shorts too
var videos: List<FreeTubeVideo> = listOf(),
)

View File

@ -0,0 +1,11 @@
package com.github.libretube.obj
import kotlinx.serialization.Serializable
@Serializable
data class FreeTubeVideo(
val videoId: String,
val title: String,
val author: String,
val authorId: String,
)

View File

@ -3,7 +3,7 @@ package com.github.libretube.obj
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ImportPlaylist( data class PipedImportPlaylist(
var name: String? = null, var name: String? = null,
val type: String? = null, val type: String? = null,
val visibility: String? = null, val visibility: String? = null,

View File

@ -3,8 +3,8 @@ package com.github.libretube.obj
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ImportPlaylistFile( data class PipedImportPlaylistFile(
val format: String, val format: String,
val version: Int, val version: Int,
val playlists: List<ImportPlaylist> = emptyList(), val playlists: List<PipedImportPlaylist> = emptyList(),
) )

View File

@ -24,15 +24,24 @@ class BackupRestoreSettings : BasePreferenceFragment() {
private val backupDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH:mm:ss") private val backupDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH:mm:ss")
private var backupFile = BackupFile() private var backupFile = BackupFile()
private var importFormat: ImportFormat = ImportFormat.NEWPIPE private var importFormat: ImportFormat = ImportFormat.NEWPIPE
private val importFormatList get() = listOf( private val importSubscriptionFormatList get() = listOf(
ImportFormat.NEWPIPE, ImportFormat.NEWPIPE,
ImportFormat.FREETUBE, ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV ImportFormat.YOUTUBECSV
).map { getString(it.value) } )
private val exportFormatList get() = listOf( private val exportSubscriptionFormatList get() = listOf(
ImportFormat.NEWPIPE, ImportFormat.NEWPIPE,
ImportFormat.FREETUBE ImportFormat.FREETUBE
).map { getString(it.value) } )
private val importPlaylistFormatList get() = listOf(
ImportFormat.PIPED,
ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV
)
private val exportPlaylistFormatList get() = listOf(
ImportFormat.PIPED,
ImportFormat.FREETUBE
)
override val titleResourceId: Int = R.string.backup_restore override val titleResourceId: Int = R.string.backup_restore
@ -80,14 +89,14 @@ class BackupRestoreSettings : BasePreferenceFragment() {
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
it?.forEach { it?.forEach {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
ImportHelper.importPlaylists(requireActivity(), it) ImportHelper.importPlaylists(requireActivity(), it, importFormat)
} }
} }
} }
private val createPlaylistsFile = registerForActivityResult(CreateDocument(JSON)) { private val createPlaylistsFile = registerForActivityResult(CreateDocument(JSON)) {
it?.let { it?.let {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ImportHelper.exportPlaylists(requireActivity(), it) ImportHelper.exportPlaylists(requireActivity(), it, importFormat)
} }
} }
} }
@ -115,8 +124,9 @@ class BackupRestoreSettings : BasePreferenceFragment() {
val importSubscriptions = findPreference<Preference>("import_subscriptions") val importSubscriptions = findPreference<Preference>("import_subscriptions")
importSubscriptions?.setOnPreferenceClickListener { importSubscriptions?.setOnPreferenceClickListener {
createImportFormatDialog(R.string.import_subscriptions_from, importFormatList) { val list = importSubscriptionFormatList.map { getString(it.value) }
importFormat = ImportFormat.values()[it] createImportFormatDialog(R.string.import_subscriptions_from, list) {
importFormat = importSubscriptionFormatList[it]
getSubscriptionsFile.launch("*/*") getSubscriptionsFile.launch("*/*")
} }
true true
@ -124,22 +134,31 @@ class BackupRestoreSettings : BasePreferenceFragment() {
val exportSubscriptions = findPreference<Preference>("export_subscriptions") val exportSubscriptions = findPreference<Preference>("export_subscriptions")
exportSubscriptions?.setOnPreferenceClickListener { exportSubscriptions?.setOnPreferenceClickListener {
createImportFormatDialog(R.string.export_subscriptions_to, exportFormatList) { val list = exportSubscriptionFormatList.map { getString(it.value) }
importFormat = ImportFormat.values()[it] createImportFormatDialog(R.string.export_subscriptions_to, list) {
createSubscriptionsFile.launch("subscriptions.json") importFormat = exportSubscriptionFormatList[it]
createSubscriptionsFile.launch("${getString(importFormat.value).lowercase()}-subscriptions.json")
} }
true true
} }
val importPlaylists = findPreference<Preference>("import_playlists") val importPlaylists = findPreference<Preference>("import_playlists")
importPlaylists?.setOnPreferenceClickListener { importPlaylists?.setOnPreferenceClickListener {
getPlaylistsFile.launch(arrayOf("*/*")) val list = importPlaylistFormatList.map { getString(it.value) }
createImportFormatDialog(R.string.import_playlists_from, list) {
importFormat = importPlaylistFormatList[it]
getPlaylistsFile.launch(arrayOf("*/*"))
}
true true
} }
val exportPlaylists = findPreference<Preference>("export_playlists") val exportPlaylists = findPreference<Preference>("export_playlists")
exportPlaylists?.setOnPreferenceClickListener { exportPlaylists?.setOnPreferenceClickListener {
createPlaylistsFile.launch("playlists.json") val list = exportPlaylistFormatList.map { getString(it.value) }
createImportFormatDialog(R.string.export_playlists_to, list) {
importFormat = exportPlaylistFormatList[it]
createPlaylistsFile.launch("${getString(importFormat.value).lowercase()}-playlists.json")
}
true true
} }

View File

@ -416,6 +416,9 @@
<!-- Backup & Restore Settings --> <!-- Backup & Restore Settings -->
<string name="import_subscriptions_from">Import subscriptions from</string> <string name="import_subscriptions_from">Import subscriptions from</string>
<string name="export_subscriptions_to">Export subscriptions to</string> <string name="export_subscriptions_to">Export subscriptions to</string>
<string name="import_playlists_from">Import playlists from</string>
<string name="export_playlists_to">Export playlists to</string>
<string name="import_format_piped">Piped / LibreTube</string>
<string name="import_format_newpipe">NewPipe</string> <string name="import_format_newpipe">NewPipe</string>
<string name="import_format_freetube">FreeTube</string> <string name="import_format_freetube">FreeTube</string>
<string name="import_format_youtube_csv">YouTube (CSV)</string> <string name="import_format_youtube_csv">YouTube (CSV)</string>