Basic History backup & export, Minor pooling cleanup, some docs

This commit is contained in:
Kelvin 2023-10-10 20:32:39 +02:00
parent 7ebd8f13c2
commit b1aae244de
8 changed files with 424 additions and 37 deletions

View file

@ -6,6 +6,7 @@ import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullable
import com.futo.polycentric.core.combineHashCodes
import okhttp3.internal.platform.Platform
@kotlinx.serialization.Serializable
class PlatformID {
@ -40,6 +41,8 @@ class PlatformID {
}
companion object {
val NONE = PlatformID("Unknown", null);
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
val contextName = "PlatformID";
return PlatformID(
@ -49,5 +52,9 @@ class PlatformID {
value.getOrDefault(config, "claimType", contextName, 0) ?: 0,
value.getOrDefault(config, "claimFieldType", contextName, -1) ?: -1);
}
fun asUrlID(url: String): PlatformID {
return PlatformID("URL", url, null);
}
}
}

View file

@ -0,0 +1,28 @@
package com.futo.platformplayer.api.media
class PlatformMultiClientPool {
private val _maxCap: Int;
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
constructor(maxCap: Int = -1) {
_maxCap = if(maxCap > 0)
maxCap
else 99;
}
fun getClientPooled(parentClient: IPlatformClient, capacity: Int): IPlatformClient {
val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient).apply {
this.onDead.subscribe { client, pool ->
synchronized(_clientPools) {
if(_clientPools[parentClient] == pool)
_clientPools.remove(parentClient);
}
}
}
_clientPools[parentClient]!!;
};
return pool.getClient(capacity.coerceAtMost(_maxCap));
}
}

View file

@ -1,8 +1,13 @@
package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
@kotlinx.serialization.Serializable
class HistoryVideo {
@ -18,4 +23,41 @@ class HistoryVideo {
this.position = position;
this.date = date;
}
fun toReconString(): String {
return "${video.url}|||${date.toEpochSecond()}|||${position}|||${video.name}";
}
companion object {
fun fromReconString(str: String, resolve: ((url: String)->SerializedPlatformVideo)? = null): HistoryVideo {
var index = str.indexOf("|||");
if(index < 0) throw IllegalArgumentException("Invalid history string: " + str);
val url = str.substring(0, index);
var indexNext = str.indexOf("|||", index + 3);
if(indexNext < 0) throw IllegalArgumentException("Invalid history string: " + str);
val dateSec = str.substring(index + 3, indexNext).toLong();
index = indexNext;
indexNext = str.indexOf("|||", index + 3);
if(indexNext < 0) throw IllegalArgumentException("Invalid history string: " + str);
val position = str.substring(index + 3, indexNext).toLong();
val name = str.substring(indexNext + 3);
val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
id = PlatformID.asUrlID(url),
name = name,
thumbnails = Thumbnails(),
author = PlatformAuthorLink(PlatformID.NONE, "Unknown", ""),
datetime = null,
url = url,
shareUrl = url,
duration = 0,
viewCount = -1
);
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC));
}
}
}

View file

@ -16,6 +16,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.logging.Logger
@ -201,7 +202,8 @@ class StateBackup {
);
val storesToSave = getAllMigrationStores()
.associateBy { it.name }
.mapValues { it.value.getAllReconstructionStrings() };
.mapValues { it.value.getAllReconstructionStrings() }
.toMutableMap();
val settings = Settings.instance.encode();
val pluginSettings = StatePlugins.instance.getPlugins()
.associateBy { it.config.id }
@ -211,7 +213,12 @@ class StateBackup {
.associateBy { it.config.id }
.mapValues { it.value.config.sourceUrl!! };
return ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
//export.videoCache = StatePlaylists.instance.getHistory()
// .distinctBy { it.video.url }
// .map { it.video };
return export;
}
@ -387,6 +394,7 @@ class StateBackup {
val plugins: Map<String, String>,
val pluginSettings: Map<String, Map<String, String?>>,
) {
var videoCache: List<SerializedPlatformVideo>? = null;
fun asZip(): ByteArray {
return ByteArrayOutputStream().use { byteStream ->

View file

@ -8,6 +8,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformClientPool
import com.futo.platformplayer.api.media.PlatformMultiClientPool
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.FilterGroup
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@ -38,6 +39,7 @@ import com.futo.platformplayer.stores.*
import kotlinx.coroutines.*
import okhttp3.internal.concat
import java.time.OffsetDateTime
import kotlin.reflect.jvm.internal.impl.builtins.jvm.JavaToKotlinClassMap.PlatformMutabilityMapping
import kotlin.streams.toList
/***
@ -61,8 +63,8 @@ class StatePlatform {
private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
private val _trackerClientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
private val _channelClientPool = PlatformMultiClientPool(15);
private val _trackerClientPool = PlatformMultiClientPool(1);
private val _primaryClientPersistent = FragmentedStorage.get<StringStorage>("primaryClient");
private var _primaryClientObj : IPlatformClient? = null;
@ -233,36 +235,6 @@ class StatePlatform {
fun getClient(id: String): IPlatformClient {
return getClientOrNull(id) ?: throw IllegalArgumentException("Client with id $id does not exist");
}
fun getClientPooled(parentClient: IPlatformClient, capacity: Int): IPlatformClient {
val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient).apply {
this.onDead.subscribe { client, pool ->
synchronized(_clientPools) {
if(_clientPools[parentClient] == pool)
_clientPools.remove(parentClient);
}
}
}
_clientPools[parentClient]!!;
};
return pool.getClient(capacity);
}
fun getTrackerClientPooled(parentClient: IPlatformClient, capacity: Int): IPlatformClient {
val pool = synchronized(_trackerClientPools) {
if(!_trackerClientPools.containsKey(parentClient))
_trackerClientPools[parentClient] = PlatformClientPool(parentClient).apply {
this.onDead.subscribe { client, pool ->
synchronized(_trackerClientPools) {
if(_trackerClientPools[parentClient] == pool)
_trackerClientPools.remove(parentClient);
}
}
}
_trackerClientPools[parentClient]!!;
};
return pool.getClient(capacity);
}
fun getClientsByClaimType(claimType: Int): List<IPlatformClient> {
return getEnabledClients().filter { it.isClaimTypeSupported(claimType) };
@ -627,7 +599,7 @@ class StatePlatform {
if (baseClient !is JSClient) {
return baseClient.getPlaybackTracker(url);
}
val client = getTrackerClientPooled(baseClient, 1);
val client = _trackerClientPool.getClientPooled(baseClient, 1);
return client.getPlaybackTracker(url);
}
@ -651,7 +623,7 @@ class StatePlatform {
val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1)
getClientPooled(baseClient, usePooledClients);
_channelClientPool.getClientPooled(baseClient, usePooledClients);
else baseClient;
var lastStream: OffsetDateTime? = null;

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@ -38,6 +39,11 @@ class StatePlaylists {
})
.load();
private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history")
.withRestore(object: ReconstructStore<HistoryVideo>() {
override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString();
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo
= HistoryVideo.fromReconString(backup, null);
})
.load();
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
.withRestore(PlaylistBackup())
@ -49,7 +55,7 @@ class StatePlaylists {
val onWatchLaterChanged = Event0();
fun toMigrateCheck(): List<ManagedStore<*>> {
return listOf(playlistStore, _watchlistStore);
return listOf(playlistStore, _watchlistStore, _historyStore);
}
fun getWatchLater() : List<SerializedPlatformVideo> {
@ -122,6 +128,11 @@ class StatePlaylists {
}
if (shouldUpdate) {
//A unrecovered item
if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L)
historyVideo.video = SerializedPlatformVideo.fromVideo(video);
historyVideo.position = pos;
historyVideo.date = OffsetDateTime.now();
_historyStore.saveAsync(historyVideo);

279
docs/Content Types.md Normal file
View file

@ -0,0 +1,279 @@
# Content Types
This page will cover the various types of content that are supported, and how to present them to Grayjay.
While Grayjay is primarily used for video, it supports various types of video, audio, but also text, images, and articles. In the future more types of content support might be added!
Content can be presented as a feed object, or a detail object. Feed objects are objects you see inside feeds and overviews such as the Home and Subscription tabs. Generally detail objects have an accompanying overview object.
Feed items are often returned in pagers, the following are some plugin methods that expect a pager of feed items:
```
source.getHome()
source.getChannelContents(...)
```
Content details are generally retrieved using
```
source.getContentDetails(url)
```
Note that all detail objects can be considered feed objects, but not the other way around. When you return a detail object in places where feed object is expected, and the user tries to open said item in a detail view, the ```GetContentDetail``` call is skipped, and the item is immediately shown without loading details.
# Feed Types
Feed types represent content in a feed or overview page. Most feed types have both a thumbnail and preview visualization, where they are displayed slightly differently. The plugin is not aware of these differences though.
## PlatformContent
All feed objects inherit PlatformContent, and always have the following properties:
```kotlin
class PlatformContent
{
id: PlatformID,
name: String,
thumbnails: ThumbNails,
author: PlatformAuthorLink,
datetime: Int, // (UnixTimeStamp)
url: String
}
```
## PlatformVideo
A feed object representing a video or audio.
*Usage:*
```javascript
new PlatformVideo({
id: new PlatformID("SomePlatformName", "SomeId", config.id),
name: "Some Video Name",
thumbnails: new Thumbnails([
new Thumbnail("https://.../...", 720),
new Thumbnail("https://.../...", 1080),
]),
author: new AuthorLink(
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
"SomeAuthorName",
"https://platform.com/your/channel/url",
"../url/to/thumbnail.png"),
uploadDate: 1696880568,
duration: 120,
viewCount: 1234567,
url: "https://platform.com/your/detail/url",
isLive: false
});
```
## PlatformPost
A feed object representing a community post with text, and optionally images.
*Usage:*
```javascript
new PlatformPost{
id: new PlatformID(config.name, item?.id, config.id),
name: item?.attributes?.title,
author: getPlatformAuthorLink(item, context),
datetime: (Date.parse(item?.attributes?.published_at) / 1000),
url: item?.attributes?.url,
description: "Description of Post",
images: ["../url/to/image1.png", "../url/to/image2.png"],
thumbnails: new Thumbnails([
new Thumbnail("https://.../...", 720),
new Thumbnail("https://.../...", 1080),
])
});
```
## PlatformNestedMediaContent
A feed object representing a link to a different item (often handled by a different plugin).
An example is a Patreon video, that links to an unlisted Youtube video. If no plugin exists to handle the content, it will be opened in an in-app browser.
A nested item consists of an detail url and optional metadata such as name, description, thumbnails, etc.
*Usage:*
```javascript
new PlatformNestedMediaContent({
id: new PlatformID("SomePlatformName", "SomeId", config.id),
name: "Name of content link",
author: new AuthorLink(
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
"SomeAuthorName",
"https://platform.com/your/channel/url",
"../url/to/thumbnail.png"),,
datetime: 1696880568,
url: item?.attributes?.url,
contentUrl: "https://someplatform.com/detail/url",
contentName: "OptionalName",
contentDescription: "OptionalDescription",
contentProvider: "OptionalPlatformName",
contentThumbnails: new Thumbnails([
new Thumbnail("https://.../...", 720),
new Thumbnail("https://.../...", 1080),
])
});
```
# Detail Types
Detail types represent content on a detail page.
## PlatformVideoDetails
A detail object representing a video or audio. It inherits PlatformVideo.
### Usage:
```javascript
new PlatformVideoDetails({
id: new PlatformID("SomePlatformName", "SomeId", config.id),
name: "Some Video Name",
thumbnails: new Thumbnails([
new Thumbnail("https://.../...", 720),
new Thumbnail("https://.../...", 1080),
]),
author: new AuthorLink(
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
"SomeAuthorName",
"https://platform.com/your/channel/url",
"../url/to/thumbnail.png"),
uploadDate: 1696880568,
duration: 120,
viewCount: 1234567,
url: "https://platform.com/your/detail/url",
isLive: false,
description: "Some description",
video: new VideoSourceDescriptor([]), //See sources
live: null,
rating: new RatingLikes(123),
subtitles: []
});
```
### Live Streams
If your video is live, the ```isLive``` property should be ```true```, and the ```live``` property should be set to a ```HLSSource```, ```DashSource```, or equivelant.
### UnMuxed and Audio-Only
If your content is either audio-only (eg. music), or has seperate video/audio tracks, you want to use ```UnMuxedVideoDescriptor``` instead of ```VideoSourceDescriptor```:
```javascript
new UnMuxedVideoDescriptor(
[videoSource1, videoSource2, ...],
[audioSource1, audioSource2, ...]
);
```
### Sources
Inside a VideoDescriptor you need to provide an array of sources.
Below you can find several source types that Grayjay supports:
**Standard Url Video/Audio**
These are videos available directly on a single url.
```javascript
new VideoUrlSource({
width: 1920,
height: 1080,
container: "video/mp4",
codec: "avc1.4d401e",
name: "1080p30 mp4",
bitrate: 188103,
duration: 250,
url: "https://platform.com/some/video/url.mp4"
});
//For audio:
new AudioUrlSource({
container: "audio/mp4",
codec: "mp4a.40.2",
name: "mp4a.40.2",
bitrate: 131294,
duration: 250,
url: "https://platform.com/some/video/url.mp4a",
language: "Unknown"
});
```
**Range Url Video/Audio**
These are more complex url sources that require very specific range headers to function. They require correct initialization and index positions.
These are converted to Dash manifests.
```javascript
new VideoUrlRangeSource({
width: 1920,
height: 1080,
container: "video/mp4",
codec: "avc1.4d401e",
name: "1080p30 mp4",
bitrate: 188103,
duration: 250,
url: "https://platform.com/some/video/url.mp4",
itagId: 1234, //Optional
initStart: 0,
initEnd: 219,
indexStart: 220,
indexEnd: 791
});
//For Audio
new AudioUrlRangeSource({
container: "audio/mp4",
codec: "mp4a.40.2",
name: "mp4a.40.2",
bitrate: 131294,
duration: 250,
url: "https://platform.com/some/video/url.mp4a",
language: "Unknown"
itagId: 1234, //Optional
initStart: 0,
initEnd: 219,
indexStart: 220,
indexEnd: 791,
audioChannels: 2
});
```
**HLSSource**
These are sources that are described in a HLS Manifest.
```javascript
new HLSSource({
name: "SomeName", //Optional
duration: 250, //Optional
url: "https://platform.com/some/hls/manifest.m3u8",
priority: false, //Optional
language: "Unknown" //Optional
});
```
Generally, HLS sources deprioritized in Grayjay. However if your platform requires HLS sources to be prioritized, you set ```priority``` to ```true```.
**DashSource**
These are sources that are described in a Dash Manifest.
```javascript
new DashSource({
name: "SomeName", //Optional
duration: 250, //Optional
url: "https://platform.com/some/dash/manifest.mpd"
});
```
## PlatformPostDetails
A detail object representing a text with optionally accompanying images. The text can be either raw text or html (and possibly in future markup).
### Usage:
```javascript
new PlatformPostDetails{
id: new PlatformID(config.name, item?.id, config.id),
name: item?.attributes?.title,
author: getPlatformAuthorLink(item, context),
datetime: (Date.parse(item?.attributes?.published_at) / 1000),
url: item?.attributes?.url,
description: "Description of Post",
images: ["../url/to/image1.png", "../url/to/image2.png"],
thumbnails: new Thumbnails([
new Thumbnail("https://.../thumbnail1.png", 720),
new Thumbnail("https://.../thumbnail2.png", 1080),
]),
rating: new RatingLikes(123),
textType: Type.Text.Html/Raw/Markup,
content: "Your post content in either raw, html, or in future markup."
});
```

40
docs/Pagers.md Normal file
View file

@ -0,0 +1,40 @@
# Pagers
Within Grayjay there are several situations where Pagers are used to communicate multiple pages of data back to the app. Some examples are home feed, channel contents, comments, live events, etc.
All these pagers have exact same layout and usage, with only some very specific cases where additional functionality is exposed.
Some example of base pagers that exist:
**ContentPager** for feed objects
**ChannelPager** for channels
**PlaylistPager** for playlists
**CommentPager** for comments
An example of a pager implementation is as follows:
```javascript
class MyPlatformContentPager extends ContentPager {
constructor(someInfo) {
super([], true); //Alternatively, pass first page results in []
this.someInfo = someInfo;
}
nextPage() {
const myNewResults = //Fetch your next page
this.results = myNewResults;
this.hasMore = true; //Or false if last page
}
}
```
You can also choose to return an entirely new pager object in nextPage, but this is **NOT RECOMMENDED** as it generates a new object for every page. But can be convenient in some recursive situations.
```
nextPage() {
return new MyPlatformContentPager(...);
}
```
In this case the new pager will replace the parent.
If you ever just want to return an empty pager without any results, you can choose to directly use the base pagers as follows:
```
return new ContentPager([], false);
```
Which effectively says *"First page is empty, and no next page"*.