diff --git a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt index 0483a05f7..bae0a5c9a 100644 --- a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt +++ b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt @@ -23,19 +23,25 @@ object DatabaseHelper { 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) { - 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) val maxHistorySize = PreferenceHelper.getString( PreferenceKeys.WATCH_HISTORY_SIZE, diff --git a/app/src/main/java/com/github/libretube/enums/SupportedClient.kt b/app/src/main/java/com/github/libretube/enums/SupportedClient.kt index ba94ce8d4..504d2f1b8 100644 --- a/app/src/main/java/com/github/libretube/enums/SupportedClient.kt +++ b/app/src/main/java/com/github/libretube/enums/SupportedClient.kt @@ -7,5 +7,6 @@ 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), + YOUTUBEJSON(R.string.youtube), PIPED(R.string.import_format_piped) } diff --git a/app/src/main/java/com/github/libretube/helpers/ImportHelper.kt b/app/src/main/java/com/github/libretube/helpers/ImportHelper.kt index 7d1f1cb4c..fba7e5676 100644 --- a/app/src/main/java/com/github/libretube/helpers/ImportHelper.kt +++ b/app/src/main/java/com/github/libretube/helpers/ImportHelper.kt @@ -1,6 +1,7 @@ package com.github.libretube.helpers import android.app.Activity +import android.content.Context import android.net.Uri import android.util.Log 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.RetrofitInstance import com.github.libretube.api.SubscriptionHelper +import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHolder.Database +import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.enums.ImportFormat import com.github.libretube.extensions.TAG 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.PipedImportPlaylist import com.github.libretube.obj.PipedPlaylistFile +import com.github.libretube.obj.YouTubeWatchHistoryFileItem import com.github.libretube.ui.dialogs.ShareDialog import com.github.libretube.util.TextUtils import kotlinx.serialization.ExperimentalSerializationApi @@ -27,6 +31,8 @@ import kotlinx.serialization.json.encodeToStream import java.util.stream.Collectors object ImportHelper { + private const val IMPORT_THUMBNAIL_QUALITY = "mqdefault" + /** * Import subscriptions by a file uri */ @@ -254,4 +260,43 @@ object ImportHelper { 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>(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) + } + } } diff --git a/app/src/main/java/com/github/libretube/obj/YouTubeWatchHistoryChannelInfo.kt b/app/src/main/java/com/github/libretube/obj/YouTubeWatchHistoryChannelInfo.kt new file mode 100644 index 000000000..ebf945795 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/YouTubeWatchHistoryChannelInfo.kt @@ -0,0 +1,9 @@ +package com.github.libretube.obj + +import kotlinx.serialization.Serializable + +@Serializable +data class YouTubeWatchHistoryChannelInfo( + val name: String, + val url: String +) \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/obj/YouTubeWatchHistoryFileItem.kt b/app/src/main/java/com/github/libretube/obj/YouTubeWatchHistoryFileItem.kt new file mode 100644 index 000000000..939c87d90 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/YouTubeWatchHistoryFileItem.kt @@ -0,0 +1,14 @@ +package com.github.libretube.obj + +import kotlinx.serialization.Serializable + +@Serializable +data class YouTubeWatchHistoryFileItem( + val activityControls: List, + val header: String, + val products: List, + val subtitles: List, + val time: String, + val title: String, + val titleUrl: String +) \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt index 5b40e0060..1c0fdaa43 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt @@ -3,6 +3,7 @@ package com.github.libretube.ui.adapters import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.os.bundleOf +import androidx.core.view.isGone import androidx.recyclerview.widget.RecyclerView import com.github.libretube.constants.IntentData import com.github.libretube.databinding.VideoRowBinding @@ -54,9 +55,19 @@ class WatchHistoryAdapter( videoTitle.text = video.title channelName.text = video.uploader videoInfo.text = video.uploadDate?.let { TextUtils.localizeDate(it) } - thumbnailDuration.setFormattedDuration(video.duration!!, null) 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 { NavigationHelper.navigateChannel(root.context, video.uploaderUrl) @@ -81,7 +92,7 @@ class WatchHistoryAdapter( true } - watchProgress.setWatchProgressLength(video.videoId, video.duration) + if (video.duration != null) watchProgress.setWatchProgressLength(video.videoId, video.duration) } } } diff --git a/app/src/main/java/com/github/libretube/ui/preferences/BackupRestoreSettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/BackupRestoreSettings.kt index bf17716eb..50b9dc79b 100644 --- a/app/src/main/java/com/github/libretube/ui/preferences/BackupRestoreSettings.kt +++ b/app/src/main/java/com/github/libretube/ui/preferences/BackupRestoreSettings.kt @@ -29,28 +29,25 @@ 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 importSubscriptionFormatList - get() = listOf( - ImportFormat.NEWPIPE, - ImportFormat.FREETUBE, - ImportFormat.YOUTUBECSV - ) - private val exportSubscriptionFormatList - get() = listOf( - ImportFormat.NEWPIPE, - ImportFormat.FREETUBE - ) - private val importPlaylistFormatList - get() = listOf( - ImportFormat.PIPED, - ImportFormat.FREETUBE, - ImportFormat.YOUTUBECSV - ) - private val exportPlaylistFormatList - get() = listOf( - ImportFormat.PIPED, - ImportFormat.FREETUBE - ) + private val importSubscriptionFormatList = listOf( + ImportFormat.NEWPIPE, + ImportFormat.FREETUBE, + ImportFormat.YOUTUBECSV + ) + private val exportSubscriptionFormatList = listOf( + ImportFormat.NEWPIPE, + ImportFormat.FREETUBE + ) + private val importPlaylistFormatList = listOf( + ImportFormat.PIPED, + ImportFormat.FREETUBE, + ImportFormat.YOUTUBECSV + ) + private val exportPlaylistFormatList = listOf( + ImportFormat.PIPED, + ImportFormat.FREETUBE + ) + private val importWatchHistoryFormatList = listOf(ImportFormat.YOUTUBEJSON) 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 = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { 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)) { it?.let { lifecycleScope.launch(Dispatchers.IO) { @@ -179,6 +184,16 @@ class BackupRestoreSettings : BasePreferenceFragment() { true } + val importWatchHistory = findPreference("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( BACKUP_DIALOG_REQUEST_KEY, this diff --git a/app/src/main/res/layout/video_row.xml b/app/src/main/res/layout/video_row.xml index 20d709c14..824e71912 100644 --- a/app/src/main/res/layout/video_row.xml +++ b/app/src/main/res/layout/video_row.xml @@ -35,6 +35,7 @@ tools:srcCompat="@tools:sample/backgrounds/scenic" /> You\'ve seen all new videos Import playlists Export playlists + Import watch history + Please note that not everything will be imported due to YouTube\'s limited export data. App Backup Import & export subscriptions, playlists, … Exported. @@ -474,6 +476,7 @@ NewPipe FreeTube YouTube (CSV) + YouTube (JSON) Home tab content Show search suggestions %1$s - %2$s diff --git a/app/src/main/res/xml/import_export_settings.xml b/app/src/main/res/xml/import_export_settings.xml index a0ee3759e..c7685c58e 100644 --- a/app/src/main/res/xml/import_export_settings.xml +++ b/app/src/main/res/xml/import_export_settings.xml @@ -31,6 +31,15 @@ + + + + + +