feat: support for importing watch history from YouTube

This commit is contained in:
Bnyro 2024-09-26 16:48:55 +02:00
parent 217d671a34
commit 5e556f4be2
10 changed files with 154 additions and 40 deletions

View File

@ -23,8 +23,8 @@ object DatabaseHelper {
videoId, videoId,
streams.toStreamItem(videoId) streams.toStreamItem(videoId)
) )
suspend fun addToWatchHistory(videoId: String, stream: StreamItem) =
withContext(Dispatchers.IO) { suspend fun addToWatchHistory(videoId: String, stream: StreamItem) {
val watchHistoryItem = WatchHistoryItem( val watchHistoryItem = WatchHistoryItem(
videoId, videoId,
stream.title, stream.title,
@ -36,6 +36,12 @@ object DatabaseHelper {
stream.thumbnail, stream.thumbnail,
stream.duration stream.duration
) )
addToWatchHistory(watchHistoryItem)
}
suspend fun addToWatchHistory(watchHistoryItem: WatchHistoryItem) =
withContext(Dispatchers.IO) {
Database.watchHistoryDao().insert(watchHistoryItem) Database.watchHistoryDao().insert(watchHistoryItem)
val maxHistorySize = PreferenceHelper.getString( val maxHistorySize = PreferenceHelper.getString(
PreferenceKeys.WATCH_HISTORY_SIZE, PreferenceKeys.WATCH_HISTORY_SIZE,

View File

@ -7,5 +7,6 @@ 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),
YOUTUBEJSON(R.string.youtube),
PIPED(R.string.import_format_piped) PIPED(R.string.import_format_piped)
} }

View File

@ -1,6 +1,7 @@
package com.github.libretube.helpers package com.github.libretube.helpers
import android.app.Activity import android.app.Activity
import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import com.github.libretube.R import com.github.libretube.R
@ -8,7 +9,9 @@ import com.github.libretube.api.JsonHelper
import com.github.libretube.api.PlaylistsHelper 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.DatabaseHelper
import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.WatchHistoryItem
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,6 +22,7 @@ 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.PipedPlaylistFile 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
import com.github.libretube.util.TextUtils import com.github.libretube.util.TextUtils
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@ -27,6 +31,8 @@ import kotlinx.serialization.json.encodeToStream
import java.util.stream.Collectors import java.util.stream.Collectors
object ImportHelper { object ImportHelper {
private const val IMPORT_THUMBNAIL_QUALITY = "mqdefault"
/** /**
* Import subscriptions by a file uri * Import subscriptions by a file uri
*/ */
@ -254,4 +260,43 @@ object ImportHelper {
else -> Unit else -> Unit
} }
} }
@OptIn(ExperimentalSerializationApi::class)
suspend fun importWatchHistory(context: Context, uri: Uri, importFormat: ImportFormat) {
val videos = when (importFormat) {
ImportFormat.YOUTUBEJSON -> {
context.contentResolver.openInputStream(uri)?.use {
JsonHelper.json.decodeFromStream<List<YouTubeWatchHistoryFileItem>>(it)
}
.orEmpty()
.filter { it.activityControls.contains("YouTube watch history") }
.reversed()
.map {
val videoId = it.titleUrl.substring(it.titleUrl.length - 11)
WatchHistoryItem(
videoId = videoId,
title = it.title.replaceFirst("Watched ", ""),
uploader = it.subtitles.firstOrNull()?.name,
uploaderUrl = it.subtitles.firstOrNull()?.url?.let { url ->
url.substring(url.length - 24)
},
thumbnailUrl = "https://img.youtube.com/vi/${videoId}/${IMPORT_THUMBNAIL_QUALITY}.jpg"
)
}
}
else -> emptyList()
}
for (video in videos) {
DatabaseHelper.addToWatchHistory(video)
}
if (videos.isEmpty()) {
context.toastFromMainDispatcher(R.string.emptyList)
} else {
context.toastFromMainDispatcher(R.string.success)
}
}
} }

View File

@ -0,0 +1,9 @@
package com.github.libretube.obj
import kotlinx.serialization.Serializable
@Serializable
data class YouTubeWatchHistoryChannelInfo(
val name: String,
val url: String
)

View File

@ -0,0 +1,14 @@
package com.github.libretube.obj
import kotlinx.serialization.Serializable
@Serializable
data class YouTubeWatchHistoryFileItem(
val activityControls: List<String>,
val header: String,
val products: List<String>,
val subtitles: List<YouTubeWatchHistoryChannelInfo>,
val time: String,
val title: String,
val titleUrl: String
)

View File

@ -3,6 +3,7 @@ package com.github.libretube.ui.adapters
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isGone
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.VideoRowBinding import com.github.libretube.databinding.VideoRowBinding
@ -54,9 +55,19 @@ class WatchHistoryAdapter(
videoTitle.text = video.title videoTitle.text = video.title
channelName.text = video.uploader channelName.text = video.uploader
videoInfo.text = video.uploadDate?.let { TextUtils.localizeDate(it) } videoInfo.text = video.uploadDate?.let { TextUtils.localizeDate(it) }
thumbnailDuration.setFormattedDuration(video.duration!!, null)
ImageHelper.loadImage(video.thumbnailUrl, thumbnail) ImageHelper.loadImage(video.thumbnailUrl, thumbnail)
if (video.duration != null) {
thumbnailDuration.setFormattedDuration(video.duration, null)
} else {
thumbnailDurationCard.isGone = true
}
if (video.uploaderAvatar != null) {
ImageHelper.loadImage(video.uploaderAvatar, channelImage, true) ImageHelper.loadImage(video.uploaderAvatar, channelImage, true)
} else {
channelImage.isGone = true
}
channelImage.setOnClickListener { channelImage.setOnClickListener {
NavigationHelper.navigateChannel(root.context, video.uploaderUrl) NavigationHelper.navigateChannel(root.context, video.uploaderUrl)
@ -81,7 +92,7 @@ class WatchHistoryAdapter(
true true
} }
watchProgress.setWatchProgressLength(video.videoId, video.duration) if (video.duration != null) watchProgress.setWatchProgressLength(video.videoId, video.duration)
} }
} }
} }

View File

@ -29,28 +29,25 @@ 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 importSubscriptionFormatList private val importSubscriptionFormatList = listOf(
get() = listOf(
ImportFormat.NEWPIPE, ImportFormat.NEWPIPE,
ImportFormat.FREETUBE, ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV ImportFormat.YOUTUBECSV
) )
private val exportSubscriptionFormatList private val exportSubscriptionFormatList = listOf(
get() = listOf(
ImportFormat.NEWPIPE, ImportFormat.NEWPIPE,
ImportFormat.FREETUBE ImportFormat.FREETUBE
) )
private val importPlaylistFormatList private val importPlaylistFormatList = listOf(
get() = listOf(
ImportFormat.PIPED, ImportFormat.PIPED,
ImportFormat.FREETUBE, ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV ImportFormat.YOUTUBECSV
) )
private val exportPlaylistFormatList private val exportPlaylistFormatList = listOf(
get() = listOf(
ImportFormat.PIPED, ImportFormat.PIPED,
ImportFormat.FREETUBE ImportFormat.FREETUBE
) )
private val importWatchHistoryFormatList = listOf(ImportFormat.YOUTUBEJSON)
override val titleResourceId: Int = R.string.backup_restore override val titleResourceId: Int = R.string.backup_restore
@ -94,9 +91,8 @@ class BackupRestoreSettings : BasePreferenceFragment() {
} }
} }
/**
* result listeners for importing and exporting playlists // result listeners for importing and exporting playlists
*/
private val getPlaylistsFile = private val getPlaylistsFile =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
it?.forEach { it?.forEach {
@ -106,6 +102,15 @@ class BackupRestoreSettings : BasePreferenceFragment() {
} }
} }
private val getWatchHistoryFile =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
it?.forEach {
CoroutineScope(Dispatchers.IO).launch {
ImportHelper.importWatchHistory(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) {
@ -179,6 +184,16 @@ class BackupRestoreSettings : BasePreferenceFragment() {
true true
} }
val importWatchHistory = findPreference<Preference>("import_watch_history")
importWatchHistory?.setOnPreferenceClickListener {
val list = importWatchHistoryFormatList.map { getString(it.value) }
createImportFormatDialog(R.string.import_watch_history, list) {
importFormat = importWatchHistoryFormatList[it]
getWatchHistoryFile.launch(arrayOf("*/*"))
}
true
}
childFragmentManager.setFragmentResultListener( childFragmentManager.setFragmentResultListener(
BACKUP_DIALOG_REQUEST_KEY, BACKUP_DIALOG_REQUEST_KEY,
this this

View File

@ -35,6 +35,7 @@
tools:srcCompat="@tools:sample/backgrounds/scenic" /> tools:srcCompat="@tools:sample/backgrounds/scenic" />
<androidx.cardview.widget.CardView <androidx.cardview.widget.CardView
android:id="@+id/thumbnail_duration_card"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="end" android:layout_gravity="end"

View File

@ -361,6 +361,8 @@
<string name="all_caught_up_summary">You\'ve seen all new videos</string> <string name="all_caught_up_summary">You\'ve seen all new videos</string>
<string name="import_playlists">Import playlists</string> <string name="import_playlists">Import playlists</string>
<string name="export_playlists">Export playlists</string> <string name="export_playlists">Export playlists</string>
<string name="import_watch_history">Import watch history</string>
<string name="import_watch_history_desc">Please note that not everything will be imported due to YouTube\'s limited export data.</string>
<string name="app_backup">App Backup</string> <string name="app_backup">App Backup</string>
<string name="backup_restore_summary">Import &amp; export subscriptions, playlists, …</string> <string name="backup_restore_summary">Import &amp; export subscriptions, playlists, …</string>
<string name="exportsuccess">Exported.</string> <string name="exportsuccess">Exported.</string>
@ -474,6 +476,7 @@
<string name="import_format_newpipe" translatable="false">NewPipe</string> <string name="import_format_newpipe" translatable="false">NewPipe</string>
<string name="import_format_freetube" translatable="false">FreeTube</string> <string name="import_format_freetube" translatable="false">FreeTube</string>
<string name="import_format_youtube_csv" translatable="false">YouTube (CSV)</string> <string name="import_format_youtube_csv" translatable="false">YouTube (CSV)</string>
<string name="import_format_youtube_json" translatable="false">YouTube (JSON)</string>
<string name="home_tab_content">Home tab content</string> <string name="home_tab_content">Home tab content</string>
<string name="show_search_suggestions">Show search suggestions</string> <string name="show_search_suggestions">Show search suggestions</string>
<string name="audio_track_format">%1$s - %2$s</string> <string name="audio_track_format">%1$s - %2$s</string>

View File

@ -31,6 +31,15 @@
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory app:title="@string/watch_history">
<Preference
android:icon="@drawable/ic_download_filled"
app:key="import_watch_history"
app:title="@string/import_watch_history" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/app_backup"> <PreferenceCategory app:title="@string/app_backup">
<Preference <Preference