mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-29 00:10:32 +05:30
feat: support for importing watch history from YouTube
This commit is contained in:
parent
217d671a34
commit
5e556f4be2
@ -23,19 +23,25 @@ object DatabaseHelper {
|
|||||||
videoId,
|
videoId,
|
||||||
streams.toStreamItem(videoId)
|
streams.toStreamItem(videoId)
|
||||||
)
|
)
|
||||||
suspend fun addToWatchHistory(videoId: String, stream: StreamItem) =
|
|
||||||
|
suspend fun addToWatchHistory(videoId: String, stream: StreamItem) {
|
||||||
|
val watchHistoryItem = WatchHistoryItem(
|
||||||
|
videoId,
|
||||||
|
stream.title,
|
||||||
|
Instant.fromEpochMilliseconds(stream.uploaded)
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date,
|
||||||
|
stream.uploaderName,
|
||||||
|
stream.uploaderUrl?.toID(),
|
||||||
|
stream.uploaderAvatar,
|
||||||
|
stream.thumbnail,
|
||||||
|
stream.duration
|
||||||
|
)
|
||||||
|
|
||||||
|
addToWatchHistory(watchHistoryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addToWatchHistory(watchHistoryItem: WatchHistoryItem) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val watchHistoryItem = WatchHistoryItem(
|
|
||||||
videoId,
|
|
||||||
stream.title,
|
|
||||||
Instant.fromEpochMilliseconds(stream.uploaded)
|
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date,
|
|
||||||
stream.uploaderName,
|
|
||||||
stream.uploaderUrl?.toID(),
|
|
||||||
stream.uploaderAvatar,
|
|
||||||
stream.thumbnail,
|
|
||||||
stream.duration
|
|
||||||
)
|
|
||||||
Database.watchHistoryDao().insert(watchHistoryItem)
|
Database.watchHistoryDao().insert(watchHistoryItem)
|
||||||
val maxHistorySize = PreferenceHelper.getString(
|
val maxHistorySize = PreferenceHelper.getString(
|
||||||
PreferenceKeys.WATCH_HISTORY_SIZE,
|
PreferenceKeys.WATCH_HISTORY_SIZE,
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
package com.github.libretube.obj
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class YouTubeWatchHistoryChannelInfo(
|
||||||
|
val name: String,
|
||||||
|
val url: String
|
||||||
|
)
|
@ -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
|
||||||
|
)
|
@ -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)
|
||||||
ImageHelper.loadImage(video.uploaderAvatar, channelImage, true)
|
|
||||||
|
if (video.duration != null) {
|
||||||
|
thumbnailDuration.setFormattedDuration(video.duration, null)
|
||||||
|
} else {
|
||||||
|
thumbnailDurationCard.isGone = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.uploaderAvatar != null) {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 = listOf(
|
||||||
private val exportSubscriptionFormatList
|
ImportFormat.NEWPIPE,
|
||||||
get() = listOf(
|
ImportFormat.FREETUBE
|
||||||
ImportFormat.NEWPIPE,
|
)
|
||||||
ImportFormat.FREETUBE
|
private val importPlaylistFormatList = listOf(
|
||||||
)
|
ImportFormat.PIPED,
|
||||||
private val importPlaylistFormatList
|
ImportFormat.FREETUBE,
|
||||||
get() = listOf(
|
ImportFormat.YOUTUBECSV
|
||||||
ImportFormat.PIPED,
|
)
|
||||||
ImportFormat.FREETUBE,
|
private val exportPlaylistFormatList = listOf(
|
||||||
ImportFormat.YOUTUBECSV
|
ImportFormat.PIPED,
|
||||||
)
|
ImportFormat.FREETUBE
|
||||||
private val exportPlaylistFormatList
|
)
|
||||||
get() = listOf(
|
private val importWatchHistoryFormatList = listOf(ImportFormat.YOUTUBEJSON)
|
||||||
ImportFormat.PIPED,
|
|
||||||
ImportFormat.FREETUBE
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
||||||
|
@ -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"
|
||||||
|
@ -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 & export subscriptions, playlists, …</string>
|
<string name="backup_restore_summary">Import & 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>
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user