Merge pull request #5667 from Bnyro/master

feat: make LibreTube app backups import-compatible with Piped
This commit is contained in:
Bnyro 2024-02-27 14:07:09 +01:00 committed by GitHub
commit 19bc8025c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 78 additions and 107 deletions

View File

@ -1,11 +1,16 @@
package com.github.libretube.db.obj package com.github.libretube.db.obj
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.libretube.ui.dialogs.ShareDialog
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@Entity(tableName = "localSubscription") @Entity(tableName = "localSubscription")
data class LocalSubscription( data class LocalSubscription(
@PrimaryKey val channelId: String = "" @PrimaryKey val channelId: String,
) @Ignore val url: String = "",
) {
constructor(channelId: String): this(channelId, "${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/$channelId")
}

View File

@ -2,12 +2,19 @@ package com.github.libretube.db.obj
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable @Serializable
@OptIn(ExperimentalSerializationApi::class)
@Entity(tableName = "subscriptionGroups") @Entity(tableName = "subscriptionGroups")
data class SubscriptionGroup( data class SubscriptionGroup(
@PrimaryKey var name: String, @PrimaryKey
@SerialName("groupName")
@JsonNames("groupName", "name")
var name: String,
var channels: List<String> = listOf(), var channels: List<String> = listOf(),
var index: Int = 0 var index: Int = 0
) )

View File

@ -49,10 +49,10 @@ object BackupHelper {
Database.watchHistoryDao().insertAll(backupFile.watchHistory.orEmpty()) Database.watchHistoryDao().insertAll(backupFile.watchHistory.orEmpty())
Database.searchHistoryDao().insertAll(backupFile.searchHistory.orEmpty()) Database.searchHistoryDao().insertAll(backupFile.searchHistory.orEmpty())
Database.watchPositionDao().insertAll(backupFile.watchPositions.orEmpty()) Database.watchPositionDao().insertAll(backupFile.watchPositions.orEmpty())
Database.localSubscriptionDao().insertAll(backupFile.localSubscriptions.orEmpty()) Database.localSubscriptionDao().insertAll(backupFile.subscriptions.orEmpty())
Database.customInstanceDao().insertAll(backupFile.customInstances.orEmpty()) Database.customInstanceDao().insertAll(backupFile.customInstances.orEmpty())
Database.playlistBookmarkDao().insertAll(backupFile.playlistBookmarks.orEmpty()) Database.playlistBookmarkDao().insertAll(backupFile.playlistBookmarks.orEmpty())
Database.subscriptionGroupsDao().insertAll(backupFile.channelGroups.orEmpty()) Database.subscriptionGroupsDao().insertAll(backupFile.groups.orEmpty())
backupFile.localPlaylists?.forEach { backupFile.localPlaylists?.forEach {
// the playlist will be created with an id of 0, so that Room will auto generate a // the playlist will be created with an id of 0, so that Room will auto generate a
@ -72,6 +72,7 @@ object BackupHelper {
*/ */
private fun restorePreferences(context: Context, preferences: List<PreferenceItem>?) { private fun restorePreferences(context: Context, preferences: List<PreferenceItem>?) {
if (preferences == null) return if (preferences == null) return
PreferenceManager.getDefaultSharedPreferences(context).edit(commit = true) { PreferenceManager.getDefaultSharedPreferences(context).edit(commit = true) {
// clear the previous settings // clear the previous settings
clear() clear()

View File

@ -9,7 +9,6 @@ import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.SubscriptionGroup
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
@ -19,8 +18,8 @@ import com.github.libretube.obj.FreetubeSubscriptions
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 com.github.libretube.obj.PipedImportPlaylist import com.github.libretube.obj.PipedImportPlaylist
import com.github.libretube.obj.PipedBackupFile import com.github.libretube.obj.PipedPlaylistFile
import com.github.libretube.obj.PipedChannelGroup import com.github.libretube.ui.dialogs.ShareDialog
import java.util.Date import java.util.Date
import java.util.stream.Collectors import java.util.stream.Collectors
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@ -63,7 +62,7 @@ object ImportHelper {
JsonHelper.json.decodeFromStream<NewPipeSubscriptions>(it) JsonHelper.json.decodeFromStream<NewPipeSubscriptions>(it)
} }
subscriptions?.subscriptions.orEmpty().map { subscriptions?.subscriptions.orEmpty().map {
it.url.replace("https://www.youtube.com/channel/", "") it.url.replace("${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/", "")
} }
} }
@ -72,7 +71,7 @@ object ImportHelper {
JsonHelper.json.decodeFromStream<FreetubeSubscriptions>(it) JsonHelper.json.decodeFromStream<FreetubeSubscriptions>(it)
} }
subscriptions?.subscriptions.orEmpty().map { subscriptions?.subscriptions.orEmpty().map {
it.url.replace("https://www.youtube.com/channel/", "") it.url.replace("${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/", "")
} }
} }
@ -107,7 +106,7 @@ object ImportHelper {
when (importFormat) { when (importFormat) {
ImportFormat.NEWPIPE -> { ImportFormat.NEWPIPE -> {
val newPipeChannels = subs.map { val newPipeChannels = subs.map {
NewPipeSubscription(it.name, 0, "https://www.youtube.com${it.url}") NewPipeSubscription(it.name, 0, "${ShareDialog.YOUTUBE_FRONTEND_URL}${it.url}")
} }
val newPipeSubscriptions = NewPipeSubscriptions(subscriptions = newPipeChannels) val newPipeSubscriptions = NewPipeSubscriptions(subscriptions = newPipeChannels)
activity.contentResolver.openOutputStream(uri)?.use { activity.contentResolver.openOutputStream(uri)?.use {
@ -117,7 +116,7 @@ object ImportHelper {
ImportFormat.FREETUBE -> { ImportFormat.FREETUBE -> {
val freeTubeChannels = subs.map { val freeTubeChannels = subs.map {
FreetubeSubscription(it.name, "", "https://www.youtube.com${it.url}") FreetubeSubscription(it.name, "", "${ShareDialog.YOUTUBE_FRONTEND_URL}${it.url}")
} }
val freeTubeSubscriptions = FreetubeSubscriptions(subscriptions = freeTubeChannels) val freeTubeSubscriptions = FreetubeSubscriptions(subscriptions = freeTubeChannels)
activity.contentResolver.openOutputStream(uri)?.use { activity.contentResolver.openOutputStream(uri)?.use {
@ -141,7 +140,7 @@ object ImportHelper {
when (importFormat) { when (importFormat) {
ImportFormat.PIPED -> { ImportFormat.PIPED -> {
val playlistFile = activity.contentResolver.openInputStream(uri)?.use { val playlistFile = activity.contentResolver.openInputStream(uri)?.use {
JsonHelper.json.decodeFromStream<PipedBackupFile>(it) JsonHelper.json.decodeFromStream<PipedPlaylistFile>(it)
} }
importPlaylists.addAll(playlistFile?.playlists.orEmpty()) importPlaylists.addAll(playlistFile?.playlists.orEmpty())
@ -231,7 +230,7 @@ object ImportHelper {
when (importFormat) { when (importFormat) {
ImportFormat.PIPED -> { ImportFormat.PIPED -> {
val playlists = PlaylistsHelper.exportPipedPlaylists() val playlists = PlaylistsHelper.exportPipedPlaylists()
val playlistFile = PipedBackupFile("Piped", 1, playlists = playlists) val playlistFile = PipedPlaylistFile(playlists = playlists)
activity.contentResolver.openOutputStream(uri)?.use { activity.contentResolver.openOutputStream(uri)?.use {
JsonHelper.json.encodeToStream(playlistFile, it) JsonHelper.json.encodeToStream(playlistFile, it)
@ -251,28 +250,4 @@ object ImportHelper {
else -> Unit else -> Unit
} }
} }
@OptIn(ExperimentalSerializationApi::class)
suspend fun importGroups(activity: Activity, uri: Uri) {
val pipedFile = activity.contentResolver.openInputStream(uri)?.use {
JsonHelper.json.decodeFromStream<PipedBackupFile>(it)
} ?: return
pipedFile.groups.forEach {
val group = SubscriptionGroup(it.groupName, it.channels)
Database.subscriptionGroupsDao().createGroup(group)
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun exportGroups(activity: Activity, uri: Uri) {
val channelGroups = Database.subscriptionGroupsDao().getAll().map {
PipedChannelGroup(it.name, it.channels)
}
val pipedFile = PipedBackupFile("Piped", 1, groups = channelGroups)
activity.contentResolver.openOutputStream(uri)?.use {
JsonHelper.json.encodeToStream(pipedFile, it)
}
}
} }

View File

@ -8,17 +8,43 @@ import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.db.obj.SubscriptionGroup import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.db.obj.WatchPosition import com.github.libretube.db.obj.WatchPosition
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable @Serializable
@OptIn(ExperimentalSerializationApi::class)
data class BackupFile( data class BackupFile(
//
// some stuff for compatibility with Piped imports
//
val format: String = "Piped",
val version: Int = 1,
//
// only compatible with LibreTube itself, database objects
//
var watchHistory: List<WatchHistoryItem>? = emptyList(), var watchHistory: List<WatchHistoryItem>? = emptyList(),
var watchPositions: List<WatchPosition>? = emptyList(), var watchPositions: List<WatchPosition>? = emptyList(),
var searchHistory: List<SearchHistoryItem>? = emptyList(), var searchHistory: List<SearchHistoryItem>? = emptyList(),
var localSubscriptions: List<LocalSubscription>? = emptyList(),
var customInstances: List<CustomInstance>? = emptyList(), var customInstances: List<CustomInstance>? = emptyList(),
var playlistBookmarks: List<PlaylistBookmark>? = emptyList(), var playlistBookmarks: List<PlaylistBookmark>? = emptyList(),
var localPlaylists: List<LocalPlaylistWithVideos>? = emptyList(),
//
// Preferences, stored as a key value map
//
var preferences: List<PreferenceItem>? = emptyList(), var preferences: List<PreferenceItem>? = emptyList(),
var channelGroups: List<SubscriptionGroup>? = emptyList()
//
// Database objects with compatibility for Piped imports/exports
//
@JsonNames("groups", "channelGroups")
var groups: List<SubscriptionGroup>? = emptyList(),
@JsonNames("subscriptions", "localSubscriptions")
var subscriptions: List<LocalSubscription>? = emptyList(),
// playlists are exported in two different formats because the formats differ too much unfortunately
var localPlaylists: List<LocalPlaylistWithVideos>? = emptyList(),
var playlists: List<PipedImportPlaylist>? = emptyList(),
) )

View File

@ -1,11 +1,12 @@
package com.github.libretube.obj package com.github.libretube.obj
import com.github.libretube.ui.dialogs.ShareDialog
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class FreetubeSubscription( data class FreetubeSubscription(
val name: String, val name: String,
@SerialName("id") val serviceId: String, @SerialName("id") val channelId: String,
val url: String = "https://www.youtube.com/channel/$serviceId" val url: String = "${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/$channelId"
) )

View File

@ -1,11 +0,0 @@
package com.github.libretube.obj
import kotlinx.serialization.Serializable
@Serializable
data class PipedBackupFile(
val format: String,
val version: Int,
val playlists: List<PipedImportPlaylist> = emptyList(),
val groups: List<PipedChannelGroup> = emptyList()
)

View File

@ -1,9 +0,0 @@
package com.github.libretube.obj
import kotlinx.serialization.Serializable
@Serializable
data class PipedChannelGroup(
val groupName: String,
val channels: List<String>
)

View File

@ -0,0 +1,10 @@
package com.github.libretube.obj
import kotlinx.serialization.Serializable
@Serializable
data class PipedPlaylistFile(
val format: String = "Piped",
val version: Int = 1,
val playlists: List<PipedImportPlaylist> = emptyList()
)

View File

@ -13,7 +13,9 @@ import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.BackupFile import com.github.libretube.obj.BackupFile
import com.github.libretube.obj.PipedImportPlaylist
import com.github.libretube.obj.PreferenceItem import com.github.libretube.obj.PreferenceItem
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -40,7 +42,7 @@ class BackupDialog : DialogFragment() {
}) })
data object LocalSubscriptions : BackupOption(R.string.local_subscriptions, onSelected = { data object LocalSubscriptions : BackupOption(R.string.local_subscriptions, onSelected = {
it.localSubscriptions = Database.localSubscriptionDao().getAll() it.subscriptions = Database.localSubscriptionDao().getAll()
}) })
data object CustomInstances : BackupOption(R.string.backup_customInstances, onSelected = { data object CustomInstances : BackupOption(R.string.backup_customInstances, onSelected = {
@ -53,10 +55,16 @@ class BackupDialog : DialogFragment() {
data object LocalPlaylists : BackupOption(R.string.local_playlists, onSelected = { data object LocalPlaylists : BackupOption(R.string.local_playlists, onSelected = {
it.localPlaylists = Database.localPlaylistsDao().getAll() it.localPlaylists = Database.localPlaylistsDao().getAll()
it.playlists = it.localPlaylists?.map { (playlist, playlistVideos) ->
val videos = playlistVideos.map { item ->
"${ShareDialog.YOUTUBE_FRONTEND_URL}/watch?v=${item.videoId}"
}
PipedImportPlaylist(playlist.name, "playlist", "private", videos)
}
}) })
data object SubscriptionGroups : BackupOption(R.string.channel_groups, onSelected = { data object SubscriptionGroups : BackupOption(R.string.channel_groups, onSelected = {
it.channelGroups = Database.subscriptionGroupsDao().getAll() it.groups = Database.subscriptionGroupsDao().getAll()
}) })
data object Preferences : BackupOption(R.string.preferences, onSelected = { file -> data object Preferences : BackupOption(R.string.preferences, onSelected = { file ->

View File

@ -114,22 +114,6 @@ class BackupRestoreSettings : BasePreferenceFragment() {
} }
} }
private val getChannelGroupsFile = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
it?.forEach { uri ->
CoroutineScope(Dispatchers.IO).launch {
ImportHelper.importGroups(requireActivity(), uri)
}
}
}
private val createChannelGroupsFile = registerForActivityResult(ActivityResultContracts.CreateDocument(JSON)) {
it?.let { uri ->
lifecycleScope.launch(Dispatchers.IO) {
ImportHelper.exportGroups(requireActivity(), uri)
}
}
}
private fun createImportFormatDialog( private fun createImportFormatDialog(
@StringRes titleStringId: Int, @StringRes titleStringId: Int,
items: List<String>, items: List<String>,
@ -195,18 +179,6 @@ class BackupRestoreSettings : BasePreferenceFragment() {
true true
} }
val importChannelGroups = findPreference<Preference>("import_groups")
importChannelGroups?.setOnPreferenceClickListener {
getChannelGroupsFile.launch(arrayOf(JSON))
true
}
val exportChannelGroups = findPreference<Preference>("export_groups")
exportChannelGroups?.setOnPreferenceClickListener {
createChannelGroupsFile.launch("piped-channel-groups.json")
true
}
childFragmentManager.setFragmentResultListener( childFragmentManager.setFragmentResultListener(
BACKUP_DIALOG_REQUEST_KEY, BACKUP_DIALOG_REQUEST_KEY,
this this

View File

@ -31,20 +31,6 @@
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory app:title="@string/channel_groups">
<Preference
android:icon="@drawable/ic_download_filled"
app:key="import_groups"
app:title="@string/import_groups" />
<Preference
android:icon="@drawable/ic_upload"
app:key="export_groups"
app:title="@string/export_groups" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/app_backup"> <PreferenceCategory app:title="@string/app_backup">
<Preference <Preference