getUserHistory support

This commit is contained in:
Kelvin 2025-07-29 01:39:32 +02:00
commit 90dca2537a
18 changed files with 210 additions and 14 deletions

View file

@ -251,6 +251,9 @@ class PlatformVideo extends PlatformContent {
this.duration = obj.duration ?? -1; //Long
this.viewCount = obj.viewCount ?? -1; //Long
this.playbackTime = obj.playbackTime ?? -1;
this.playbackDate = obj.playbackDate ?? undefined;
this.isLive = obj.isLive ?? false; //Boolean
this.isShort = !!obj.isShort ?? false;
}

View file

@ -182,6 +182,10 @@ interface IPlatformClient {
* Retrieves the subscriptions of the currently logged in user
*/
fun getUserSubscriptions(): Array<String>;
/**
* Retrieves the history of the currently logged in user
*/
fun getUserHistory(): IPager<IPlatformContent>;
fun isClaimTypeSupported(claimType: Int): Boolean;

View file

@ -20,7 +20,8 @@ data class PlatformClientCapabilities(
val hasGetContentChapters: Boolean = false,
val hasPeekChannelContents: Boolean = false,
val hasGetChannelPlaylists: Boolean = false,
val hasGetContentRecommendations: Boolean = false
val hasGetContentRecommendations: Boolean = false,
val hasGetUserHistory: Boolean = false
) {
}

View file

@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import java.time.OffsetDateTime
/**
* A search result representing a video (overview data)
@ -12,6 +13,9 @@ interface IPlatformVideo : IPlatformContent {
val duration: Long;
val viewCount: Long;
val playbackTime: Long;
val playbackDate: OffsetDateTime?;
val isLive : Boolean;
val isShort: Boolean;

View file

@ -6,8 +6,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNames
@ -33,6 +31,10 @@ open class SerializedPlatformVideo(
override val isLive: Boolean = false;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override fun toJson() : String {
return Json.encodeToString(this);
}

View file

@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@ -43,6 +42,10 @@ open class SerializedPlatformVideoDetails(
) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override val isLive: Boolean get() = false;
override val dash: IDashManifestSource? get() = null;

View file

@ -272,7 +272,8 @@ open class JSClient : IPlatformClient {
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
);
try {
@ -712,6 +713,13 @@ open class JSClient : IPlatformClient {
.toTypedArray();
}
@JSOptional
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
override fun getUserHistory(): IPager<IPlatformContent> {
ensureEnabled();
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
fun validate() {
try {
plugin.start();

View file

@ -5,10 +5,16 @@ import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.fields.DropdownFieldOptions
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Serializable
@ -110,7 +116,28 @@ class SourcePluginDescriptor {
var enableShorts: Boolean? = null;
}
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
@FormField(R.string.sync, "group", R.string.sync_desc, 3)
var sync = Sync();
@Serializable
class Sync {
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1)
var enableHistorySync: Boolean? = null;
@FormField(R.string.sync_history, FieldForm.BUTTON, R.string.sync_history_desc, 2)
@FormFieldButton()
fun syncHistoryNow() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val clients = StatePlatform.instance.getEnabledClients();
for (client in clients) {
if (client is JSClient) {//) && client.descriptor.appSettings.sync.enableHistorySync == true) {
StateHistory.instance.syncRemoteHistory(client);
}
}
};
}
}
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
var rateLimit = RateLimit();
@Serializable
class RateLimit {

View file

@ -8,6 +8,10 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
final override val contentType: ContentType get() = ContentType.MEDIA;
@ -17,6 +21,10 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
final override val duration: Long;
final override val viewCount: Long;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
final override val isLive: Boolean;
final override val isShort: Boolean;
@ -29,5 +37,11 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
viewCount = _content.getOrThrow(config, "viewCount", contextName);
isLive = _content.getOrThrow(config, "isLive", contextName);
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
playbackTime = _content.getOrDefault<Long>(config, "playbackTime", contextName, -1)?.toLong() ?: -1;
val playbackDateInt = _content.getOrDefault<Int>(config, "playbackDate", contextName, null)?.toLong();
if(playbackDateInt == null || playbackDateInt == 0.toLong())
playbackDate = null;
else
playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
}
}

View file

@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@ -19,7 +18,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
@ -53,6 +52,10 @@ class LocalVideoDetails: IPlatformVideoDetails {
override val isLive: Boolean = false;
override val isShort: Boolean = false;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
constructor(file: File) {
id = PlatformID("Local", file.path, "LOCAL")
name = file.name;

View file

@ -47,10 +47,10 @@ class DeveloperEndpoints(private val context: Context) {
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject> {
val remotes = mutableListOf<V8RemoteObject>();
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject?> {
val remotes = mutableListOf<V8RemoteObject?>();
for(obj in objs)
remotes.add(createRemoteObject(obj)!!);
remotes.add(createRemoteObject(obj));
return remotes;
}
private inline fun <reified T> createRemoteObject(obj: T): V8RemoteObject? {

View file

@ -73,6 +73,10 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val isShort: Boolean get() = videoSerialized.isShort;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
//TODO: Offline subtitles
override val subtitles: List<ISubtitleSource> = listOf();

View file

@ -136,7 +136,7 @@ class V8RemoteObject {
}
fun List<V8RemoteObject>.serialize() : String {
fun List<V8RemoteObject?>.serialize() : String {
return _gson.toJson(this);
}
}

View file

@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.views.pills.WidePillButton
import java.time.OffsetDateTime
@ -152,6 +153,9 @@ class TutorialFragment : MainFragment() {
override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
override val isShort: Boolean = false;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager()
}

View file

@ -636,6 +636,20 @@ class StateApp {
}
}
}
scopeOrNull?.launch(Dispatchers.IO) {
val enabledPlugins = StatePlatform.instance.getEnabledClients();
for(plugin in enabledPlugins) {
try {
if(plugin is JSClient) {
if(plugin.descriptor.appSettings.sync.enableHistorySync == true)
StateHistory.instance.syncRemoteHistory(plugin);
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to update remote history for ${plugin.name}", ex);
}
}
}
}
fun mainAppStartedWithExternalFiles(context: Context) {

View file

@ -1,15 +1,18 @@
package com.futo.platformplayer.states
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StatePlaylists.Companion
import com.futo.platformplayer.states.StateApp.Companion
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.db.ManagedDBStore
import com.futo.platformplayer.stores.db.types.DBHistory
import com.futo.platformplayer.stores.v2.ReconstructStore
@ -19,7 +22,6 @@ import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import kotlin.math.min
class StateHistory {
//Legacy
@ -31,6 +33,8 @@ class StateHistory {
})
.load();
private val _remoteHistoryDatesStore = FragmentedStorage.get<StringDateMapStorage>("remoteHistoryDates");
private val historyIndex: ConcurrentMap<Any, DBHistory.Index> = ConcurrentHashMap();
val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor())
.withIndex({ it.url }, historyIndex, false, true)
@ -186,8 +190,95 @@ class StateHistory {
val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 };
for(item in toDelete)
_historyDBStore.delete(item);
_remoteHistoryDatesStore.map = HashMap<String, Long>();
_remoteHistoryDatesStore.save();
}
fun syncRemoteHistory(plugin: JSClient) {
if (plugin.capabilities.hasGetUserHistory &&
plugin.isLoggedIn) {
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
val hist = StatePlatform.instance.getUserHistory(plugin.id);
syncRemoteHistory(plugin.id, hist, 100, 3);
}
}
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int) {
val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN;
val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos;
val maxPageCount = if(maxPages <= 0) 3 else maxPages;
var exceededDate = false;
try {
val toSync = mutableListOf<IPlatformVideo>();
var pageCount = 0;
var videoCount = 0;
var isFirst = true;
var oldestPlayback = OffsetDateTime.MAX;
var newestPlayback = OffsetDateTime.MIN;
do {
if (!isFirst) videos.nextPage();
val newVideos = videos.getResults();
var foundVideos = false;
var toSyncAddedCount = 0;
for(video in newVideos) {
if(video is IPlatformVideo && video.playbackDate != null) {
if(video.playbackDate!! < lastDate) {
exceededDate = true;
break;
}
if(video.playbackTime > 0) {
toSync.add(video);
toSyncAddedCount++;
foundVideos = true;
oldestPlayback = video.playbackDate!!;
if(newestPlayback == OffsetDateTime.MIN)
newestPlayback = video.playbackDate!!;
}
}
}
pageCount++;
videoCount += newVideos.size;
isFirst = false;
if(!foundVideos)
{
Logger.i(TAG, "Found no more videos in remote history");
break;
}
}
while(videos.hasMorePages() && videoCount <= maxVideosCount && pageCount <= maxPageCount && !exceededDate);
var updated = 0;
if(oldestPlayback < OffsetDateTime.MAX) {
for(video in toSync){
val hist = getHistoryByVideo(video, true, video.playbackDate);
if(hist != null && hist.position < video.playbackTime) {
Logger.i(TAG, "Updated history for video [${video.name}] from remote history");
updateHistoryPosition(video, hist, true, video.playbackTime, video.playbackDate, false);
updated++;
}
}
if(updated > 0) {
_remoteHistoryDatesStore.setAndSave(pluginId, newestPlayback);
try {
val client = StatePlatform.instance.getClient(pluginId);
UIDialogs.appToast("Updated ${updated} history from ${client.name}")
}
catch(ex: Throwable){}
}
}
}
catch(ex: Throwable) {
val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null;
Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message)
}
}
companion object {
val TAG = "StateHistory";

View file

@ -1036,6 +1036,16 @@ class StatePlatform {
return client.getLiveChatWindow(url);
}
//Account
fun getUserHistory(id: String): IPager<IPlatformContent> {
val client = getClient(id);
if(client is JSClient && client.isLoggedIn) {
return client.fromPool(_pagerClientPool).getUserHistory()
}
return EmptyPager<IPlatformContent>();
}
fun injectDevPlugin(source: SourcePluginConfig, script: String): String? {
var devId: String? = null;

View file

@ -11,6 +11,8 @@
<string name="general">General</string>
<string name="channel">Channel</string>
<string name="home">Home</string>
<string name="sync_history">Sync Remote History</string>
<string name="sync_history_desc">Synchronize account history from this platform on startup</string>
<string name="progress_bar">Progress Bar</string>
<string name="advanced_settings">Advanced Settings</string>
<string name="advanced_settings_description">If advanced settings should be shown, this exposes additional settings to finetune your experience.</string>
@ -592,6 +594,8 @@
<string name="various_tests_against_a_custom_source">Various tests against a custom source</string>
<string name="writes_to_disk_till_no_space_is_left">Writes to disk till no space is left</string>
<string name="visibility">Visibility</string>
<string name="sync">Sync</string>
<string name="sync_desc">Synchronization of platform data</string>
<string name="check_for_updates_setting">Check for updates</string>
<string name="check_for_updates_setting_description">If a plugin should be checked for updates on startup</string>
<string name="automatic_update_setting">Automatic Update</string>