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.helpers.PreferenceHelper
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.async
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 ->
val playlistId = createPlaylist(playlist.name!!)
async {
@ -167,7 +169,7 @@ object PlaylistsHelper {
}.awaitAll()
}
suspend fun exportPlaylists(): List<ImportPlaylist> = withContext(Dispatchers.IO) {
suspend fun exportPipedPlaylists(): List<PipedImportPlaylist> = withContext(Dispatchers.IO) {
getPlaylists()
.map { async { getPlaylist(it.id!!) } }
.awaitAll()
@ -175,10 +177,25 @@ object PlaylistsHelper {
val videos = it.relatedStreams.map { item ->
"$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? {
if (!loggedIn) {
val playlist = RetrofitInstance.api.getPlaylist(playlistId)

View File

@ -6,5 +6,6 @@ import com.github.libretube.R
enum class ImportFormat(@StringRes val value: Int) {
NEWPIPE(R.string.import_format_newpipe),
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.extensions.TAG
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.obj.FreeTubeImportPlaylist
import com.github.libretube.obj.FreetubeSubscription
import com.github.libretube.obj.FreetubeSubscriptions
import com.github.libretube.obj.ImportPlaylist
import com.github.libretube.obj.ImportPlaylistFile
import com.github.libretube.obj.PipedImportPlaylist
import com.github.libretube.obj.PipedImportPlaylistFile
import com.github.libretube.obj.NewPipeSubscription
import com.github.libretube.obj.NewPipeSubscriptions
import kotlinx.serialization.ExperimentalSerializationApi
@ -76,6 +77,7 @@ object ImportHelper {
}
}.orEmpty()
}
else -> throw IllegalArgumentException()
}
}
@ -123,12 +125,38 @@ object ImportHelper {
* Import Playlists
*/
@OptIn(ExperimentalSerializationApi::class)
suspend fun importPlaylists(activity: Activity, uri: Uri) {
val importPlaylists = mutableListOf<ImportPlaylist>()
suspend fun importPlaylists(activity: Activity, uri: Uri, importFormat: ImportFormat) {
val importPlaylists = mutableListOf<PipedImportPlaylist>()
when (val fileType = activity.contentResolver.getType(uri)) {
"text/csv", "text/comma-separated-values" -> {
val playlist = ImportPlaylist()
when (importFormat) {
ImportFormat.PIPED -> {
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 {
val lines = it.bufferedReader().use { reader -> reader.lines().toList() }
playlist.name = lines[1].split(",").reversed()[2]
@ -144,23 +172,13 @@ object ImportHelper {
}
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
importPlaylists.forEach { playlist ->
playlist.videos = playlist.videos.map { it.takeLast(11) }
// convert the YouTube URLs to videoIds
importPlaylists.forEach { importPlaylist ->
importPlaylist.videos = importPlaylist.videos.map { it.takeLast(11) }
}
}
else -> throw IllegalArgumentException()
}
try {
PlaylistsHelper.importPlaylists(importPlaylists)
@ -177,14 +195,26 @@ object ImportHelper {
* Export Playlists
*/
@OptIn(ExperimentalSerializationApi::class)
suspend fun exportPlaylists(activity: Activity, uri: Uri) {
val playlists = PlaylistsHelper.exportPlaylists()
val playlistFile = ImportPlaylistFile("Piped", 1, playlists)
suspend fun exportPlaylists(activity: Activity, uri: Uri, importFormat: ImportFormat) {
when (importFormat) {
ImportFormat.PIPED -> {
val playlists = PlaylistsHelper.exportPipedPlaylists()
val playlistFile = PipedImportPlaylistFile("Piped", 1, playlists)
activity.contentResolver.openOutputStream(uri)?.use {
JsonHelper.json.encodeToStream(playlistFile, it)
activity.contentResolver.openOutputStream(uri)?.use {
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
@Serializable
data class ImportPlaylist(
data class PipedImportPlaylist(
var name: String? = null,
val type: String? = null,
val visibility: String? = null,

View File

@ -3,8 +3,8 @@ package com.github.libretube.obj
import kotlinx.serialization.Serializable
@Serializable
data class ImportPlaylistFile(
data class PipedImportPlaylistFile(
val format: String,
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 var backupFile = BackupFile()
private var importFormat: ImportFormat = ImportFormat.NEWPIPE
private val importFormatList get() = listOf(
private val importSubscriptionFormatList get() = listOf(
ImportFormat.NEWPIPE,
ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV
).map { getString(it.value) }
private val exportFormatList get() = listOf(
)
private val exportSubscriptionFormatList get() = listOf(
ImportFormat.NEWPIPE,
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
@ -80,14 +89,14 @@ class BackupRestoreSettings : BasePreferenceFragment() {
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
it?.forEach {
CoroutineScope(Dispatchers.IO).launch {
ImportHelper.importPlaylists(requireActivity(), it)
ImportHelper.importPlaylists(requireActivity(), it, importFormat)
}
}
}
private val createPlaylistsFile = registerForActivityResult(CreateDocument(JSON)) {
it?.let {
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")
importSubscriptions?.setOnPreferenceClickListener {
createImportFormatDialog(R.string.import_subscriptions_from, importFormatList) {
importFormat = ImportFormat.values()[it]
val list = importSubscriptionFormatList.map { getString(it.value) }
createImportFormatDialog(R.string.import_subscriptions_from, list) {
importFormat = importSubscriptionFormatList[it]
getSubscriptionsFile.launch("*/*")
}
true
@ -124,22 +134,31 @@ class BackupRestoreSettings : BasePreferenceFragment() {
val exportSubscriptions = findPreference<Preference>("export_subscriptions")
exportSubscriptions?.setOnPreferenceClickListener {
createImportFormatDialog(R.string.export_subscriptions_to, exportFormatList) {
importFormat = ImportFormat.values()[it]
createSubscriptionsFile.launch("subscriptions.json")
val list = exportSubscriptionFormatList.map { getString(it.value) }
createImportFormatDialog(R.string.export_subscriptions_to, list) {
importFormat = exportSubscriptionFormatList[it]
createSubscriptionsFile.launch("${getString(importFormat.value).lowercase()}-subscriptions.json")
}
true
}
val importPlaylists = findPreference<Preference>("import_playlists")
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
}
val exportPlaylists = findPreference<Preference>("export_playlists")
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
}

View File

@ -416,6 +416,9 @@
<!-- Backup & Restore Settings -->
<string name="import_subscriptions_from">Import subscriptions from</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_freetube">FreeTube</string>
<string name="import_format_youtube_csv">YouTube (CSV)</string>