Added Subscriptions (pinged every ~6 hours)

This commit is contained in:
no-commit 2023-02-19 19:27:40 +01:00
parent 33aecfbba5
commit 00a91ca5fb
15 changed files with 515 additions and 55 deletions

View File

@ -184,8 +184,8 @@ dependencies {
//implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
// Downloading
implementation("androidx.work:work-runtime:2.7.1")
implementation("androidx.work:work-runtime-ktx:2.7.1")
implementation("androidx.work:work-runtime:2.8.0")
implementation("androidx.work:work-runtime-ktx:2.8.0")
// Networking
// implementation("com.squareup.okhttp3:okhttp:4.9.2")

View File

@ -1327,7 +1327,7 @@ fun LoadResponse?.isAnimeBased(): Boolean {
fun TvType?.isEpisodeBased(): Boolean {
if (this == null) return false
return (this == TvType.TvSeries || this == TvType.Anime)
return (this == TvType.TvSeries || this == TvType.Anime || this == TvType.AsianDrama)
}
@ -1351,6 +1351,7 @@ interface EpisodeResponse {
var showStatus: ShowStatus?
var nextAiring: NextAiring?
var seasonNames: List<SeasonData>?
fun getLatestEpisodes(): Map<DubStatus, Int?>
}
@JvmName("addSeasonNamesString")
@ -1419,7 +1420,18 @@ data class AnimeLoadResponse(
override var nextAiring: NextAiring? = null,
override var seasonNames: List<SeasonData>? = null,
override var backgroundPosterUrl: String? = null,
) : LoadResponse, EpisodeResponse
) : LoadResponse, EpisodeResponse {
override fun getLatestEpisodes(): Map<DubStatus, Int?> {
return episodes.map { (status, episodes) ->
val maxSeason = episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }
.takeUnless { it == Int.MIN_VALUE }
status to episodes
.filter { it.season == maxSeason }
.maxOfOrNull { it.episode ?: Int.MIN_VALUE }
.takeUnless { it == Int.MIN_VALUE }
}.toMap()
}
}
/**
* If episodes already exist appends the list.
@ -1617,7 +1629,17 @@ data class TvSeriesLoadResponse(
override var nextAiring: NextAiring? = null,
override var seasonNames: List<SeasonData>? = null,
override var backgroundPosterUrl: String? = null,
) : LoadResponse, EpisodeResponse
) : LoadResponse, EpisodeResponse {
override fun getLatestEpisodes(): Map<DubStatus, Int?> {
val maxSeason =
episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }.takeUnless { it == Int.MIN_VALUE }
val max = episodes
.filter { it.season == maxSeason }
.maxOfOrNull { it.episode ?: Int.MIN_VALUE }
.takeUnless { it == Int.MIN_VALUE }
return mapOf(DubStatus.None to max)
}
}
suspend fun MainAPI.newTvSeriesLoadResponse(
name: String,

View File

@ -16,6 +16,7 @@ import com.google.gson.Gson
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
@ -165,11 +166,11 @@ object PluginManager {
private var loadedLocalPlugins = false
private val gson = Gson()
private suspend fun maybeLoadPlugin(activity: Activity, file: File) {
private suspend fun maybeLoadPlugin(context: Context, file: File) {
val name = file.name
if (file.extension == "zip" || file.extension == "cs3") {
loadPlugin(
activity,
context,
file,
PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET)
)
@ -199,7 +200,7 @@ object PluginManager {
// var allCurrentOutDatedPlugins: Set<OnlinePluginData> = emptySet()
suspend fun loadSinglePlugin(activity: Activity, apiName: String): Boolean {
suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean {
return (getPluginsOnline().firstOrNull {
// Most of the time the provider ends with Provider which isn't part of the api name
it.internalName.replace("provider", "", ignoreCase = true) == apiName
@ -209,7 +210,7 @@ object PluginManager {
})?.let { savedData ->
// OnlinePluginData(savedData, onlineData)
loadPlugin(
activity,
context,
File(savedData.filePath),
savedData
)
@ -371,11 +372,11 @@ object PluginManager {
/**
* Use updateAllOnlinePluginsAndLoadThem
* */
fun loadAllOnlinePlugins(activity: Activity) {
fun loadAllOnlinePlugins(context: Context) {
// Load all plugins as fast as possible!
(getPluginsOnline()).toList().apmap { pluginData ->
loadPlugin(
activity,
context,
File(pluginData.filePath),
pluginData
)
@ -398,7 +399,7 @@ object PluginManager {
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
* and reload all pages even if they are previously valid
**/
fun loadAllLocalPlugins(activity: Activity, forceReload: Boolean) {
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
val dir = File(LOCAL_PLUGINS_PATH)
removeKey(PLUGINS_KEY_LOCAL)
@ -416,7 +417,7 @@ object PluginManager {
Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins")
sortedPlugins?.sortedBy { it.name }?.apmap { file ->
maybeLoadPlugin(activity, file)
maybeLoadPlugin(context, file)
}
loadedLocalPlugins = true
@ -441,14 +442,14 @@ object PluginManager {
/**
* @return True if successful, false if not
* */
private suspend fun loadPlugin(activity: Activity, file: File, data: PluginData): Boolean {
private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
val fileName = file.nameWithoutExtension
val filePath = file.absolutePath
currentlyLoading = fileName
Log.i(TAG, "Loading plugin: $data")
return try {
val loader = PathClassLoader(filePath, activity.classLoader)
val loader = PathClassLoader(filePath, context.classLoader)
var manifest: Plugin.Manifest
loader.getResourceAsStream("manifest.json").use { stream ->
if (stream == null) {
@ -492,22 +493,22 @@ object PluginManager {
addAssetPath.invoke(assets, file.absolutePath)
pluginInstance.resources = Resources(
assets,
activity.resources.displayMetrics,
activity.resources.configuration
context.resources.displayMetrics,
context.resources.configuration
)
}
plugins[filePath] = pluginInstance
classLoaders[loader] = pluginInstance
urlPlugins[data.url ?: filePath] = pluginInstance
pluginInstance.load(activity)
pluginInstance.load(context)
Log.i(TAG, "Loaded plugin ${data.internalName} successfully")
currentlyLoading = null
true
} catch (e: Throwable) {
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
showToast(
activity,
activity.getString(R.string.plugin_load_fail).format(fileName),
context.getActivity(),
context.getString(R.string.plugin_load_fail).format(fileName),
Toast.LENGTH_LONG
)
currentlyLoading = null

View File

@ -0,0 +1,218 @@
package com.lagradost.cloudstream3.services
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.work.*
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit
const val SUBSCRIPTION_CHANNEL_ID = "cloudstream3.subscriptions"
const val SUBSCRIPTION_WORK_NAME = "work_subscription"
const val SUBSCRIPTION_CHANNEL_NAME = "Subscriptions"
const val SUBSCRIPTION_CHANNEL_DESCRIPTION = "Notifications for new episodes on subscribed shows"
const val SUBSCRIPTION_NOTIFICATION_ID = 938712897 // Random unique
class SubscriptionWorkManager(val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
companion object {
fun enqueuePeriodicWork(context: Context?) {
if (context == null) return
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val periodicSyncDataWork =
PeriodicWorkRequest.Builder(SubscriptionWorkManager::class.java, 6, TimeUnit.HOURS)
.addTag(SUBSCRIPTION_WORK_NAME)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
SUBSCRIPTION_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
periodicSyncDataWork
)
// Uncomment below for testing
// val oneTimeSyncDataWork =
// OneTimeWorkRequest.Builder(SubscriptionWorkManager::class.java)
// .addTag(SUBSCRIPTION_WORK_NAME)
// .setConstraints(constraints)
// .build()
//
// WorkManager.getInstance(context).enqueue(oneTimeSyncDataWork)
}
}
private val progressNotificationBuilder =
NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID)
.setAutoCancel(false)
.setColorized(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
.setContentTitle(context.getString(R.string.subscription_in_progress_notification))
.setSmallIcon(R.drawable.quantum_ic_refresh_white_24)
.setProgress(0, 0, true)
private val updateNotificationBuilder =
NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID)
.setColorized(true)
.setOnlyAlertOnce(true)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
.setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
private val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private fun updateProgress(max: Int, progress: Int, indeterminate: Boolean) {
notificationManager.notify(
SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder
.setProgress(max, progress, indeterminate)
.build()
)
}
override suspend fun doWork(): Result {
// println("Update subscriptions!")
context.createNotificationChannel(
SUBSCRIPTION_CHANNEL_ID,
SUBSCRIPTION_CHANNEL_NAME,
SUBSCRIPTION_CHANNEL_DESCRIPTION
)
safeApiCall {
setForeground(
ForegroundInfo(
SUBSCRIPTION_NOTIFICATION_ID,
progressNotificationBuilder.build()
)
)
}
val subscriptions = getAllSubscriptions()
if (subscriptions.isEmpty()) {
WorkManager.getInstance(context).cancelWorkById(this.id)
return Result.success()
}
val max = subscriptions.size
var progress = 0
updateProgress(max, progress, true)
// We need all plugins loaded.
PluginManager.loadAllOnlinePlugins(context)
PluginManager.loadAllLocalPlugins(context, false)
subscriptions.apmap { savedData ->
try {
val id = savedData.id ?: return@apmap null
val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
// Reasonable timeout to prevent having this worker run forever.
val response = withTimeoutOrNull(60_000) {
api.load(savedData.url) as? EpisodeResponse
} ?: return@apmap null
val dubPreference =
getDub(id) ?: if (
context.getApiDubstatusSettings().contains(DubStatus.Dubbed)
) {
DubStatus.Dubbed
} else {
DubStatus.Subbed
}
val latestEpisodes = response.getLatestEpisodes()
val latestPreferredEpisode = latestEpisodes[dubPreference]
val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) {
val latestSeenEpisode =
savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE
val shouldUpdate = latestPreferredEpisode > latestSeenEpisode
shouldUpdate to latestPreferredEpisode
} else {
val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE
val latestSeenEpisode =
savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE
val shouldUpdate = latestEpisode > latestSeenEpisode
shouldUpdate to latestEpisode
}
DataStoreHelper.updateSubscribedData(
id,
savedData,
response
)
if (shouldUpdate) {
val updateHeader = savedData.name
val updateDescription = txt(
R.string.subscription_episode_released,
latestEpisode,
savedData.name
).asString(context)
val intent = Intent(context, MainActivity::class.java).apply {
data = savedData.url.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getActivity(context, 0, intent, 0)
}
val poster = ioWork { savedData.posterUrl?.let { url -> context.getImageBitmapFromUrl(url, savedData.posterHeaders) } }
val updateNotification =
updateNotificationBuilder.setContentTitle(updateHeader)
.setContentText(updateDescription)
.setContentIntent(pendingIntent)
.setLargeIcon(poster)
.build()
notificationManager.notify(id, updateNotification)
}
// You can probably get some issues here since this is async but it does not matter much.
updateProgress(max, ++progress, false)
} catch (_: Throwable) {
}
}
return Result.success()
}
}

View File

@ -9,6 +9,7 @@ import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
@ -74,13 +75,16 @@ class LocalList : SyncAPI {
group.value.mapNotNull {
getBookmarkedData(it.first)?.toLibraryItem(it.first.toString())
}
}
} + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
it.toLibraryItem()
})
}
val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate {
// None is not something to display
it.stringRes to emptyList<SyncAPI.LibraryItem>()
}
} + mapOf(R.string.subscription_list_name to emptyList())
return SyncAPI.LibraryMetadata(
(baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
setOf(

View File

@ -15,6 +15,7 @@ import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.core.view.isVisible
@ -27,12 +28,14 @@ import com.google.android.material.chip.ChipDrawable
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.mvvm.*
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
@ -850,7 +853,7 @@ open class ResultFragment : ResultTrailerPlayer() {
}
observe(viewModel.page) { data ->
if(data == null) return@observe
if (data == null) return@observe
when (data) {
is Resource.Success -> {
val d = data.value
@ -904,6 +907,36 @@ open class ResultFragment : ResultTrailerPlayer() {
updateList(d.actors ?: emptyList())
}
observeNullable(viewModel.subscribeStatus) { isSubscribed ->
result_subscribe?.isVisible = isSubscribed != null
if (isSubscribed == null) return@observeNullable
val drawable = if (isSubscribed) {
R.drawable.ic_baseline_notifications_active_24
} else {
R.drawable.baseline_notifications_none_24
}
result_subscribe?.setImageResource(drawable)
}
result_subscribe?.setOnClickListener {
val isSubscribed =
viewModel.toggleSubscriptionStatus() ?: return@setOnClickListener
val message = if (isSubscribed) {
// Kinda icky to have this here, but it works.
SubscriptionWorkManager.enqueuePeriodicWork(context)
R.string.subscription_new
} else {
R.string.subscription_deleted
}
val name = (viewModel.page.value as? Resource.Success)?.value?.title
?: txt(R.string.no_data).asStringNull(context) ?: ""
showToast(activity, txt(message, name), Toast.LENGTH_SHORT)
}
result_open_in_browser?.isVisible = d.url.startsWith("http")
result_open_in_browser?.setOnClickListener {
val i = Intent(ACTION_VIEW)

View File

@ -16,6 +16,7 @@ import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getId
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.getCastSession
import com.lagradost.cloudstream3.CommonActivity.showToast
@ -414,6 +415,9 @@ class ResultViewModel2 : ViewModel() {
private val _episodeSynopsis: MutableLiveData<String?> = MutableLiveData(null)
val episodeSynopsis: LiveData<String?> = _episodeSynopsis
private val _subscribeStatus: MutableLiveData<Boolean?> = MutableLiveData(null)
val subscribeStatus: LiveData<Boolean?> = _subscribeStatus
companion object {
const val TAG = "RVM2"
private const val EPISODE_RANGE_SIZE = 20
@ -815,6 +819,42 @@ class ResultViewModel2 : ViewModel() {
}
}
/**
* @return true if the new status is Subscribed, false if not. Null if not possible to subscribe.
**/
fun toggleSubscriptionStatus(): Boolean? {
val isSubscribed = _subscribeStatus.value ?: return null
val response = currentResponse ?: return null
if (response !is EpisodeResponse) return null
val currentId = response.getId()
if (isSubscribed) {
DataStoreHelper.removeSubscribedData(currentId)
} else {
val current = DataStoreHelper.getSubscribedData(currentId)
DataStoreHelper.setSubscribedData(
currentId,
DataStoreHelper.SubscribedData(
currentId,
current?.bookmarkedTime ?: unixTimeMS,
unixTimeMS,
response.getLatestEpisodes(),
response.name,
response.url,
response.apiName,
response.type,
response.posterUrl,
response.year
)
)
}
_subscribeStatus.postValue(!isSubscribed)
return !isSubscribed
}
private fun startChromecast(
activity: Activity?,
result: ResultEpisode,
@ -1473,7 +1513,8 @@ class ResultViewModel2 : ViewModel() {
this.engName,
this.name,
this.japName
).filter { it.length > 2 }.distinct(), // the reason why we filter is due to not wanting smth like " " or "?"
).filter { it.length > 2 }
.distinct(), // the reason why we filter is due to not wanting smth like " " or "?"
TrackerType.getTypes(this.type),
this.year
)
@ -1670,6 +1711,16 @@ class ResultViewModel2 : ViewModel() {
postResume()
}
private fun postSubscription(loadResponse: LoadResponse) {
if (loadResponse.isEpisodeBased()) {
val id = loadResponse.getId()
val data = DataStoreHelper.getSubscribedData(id)
DataStoreHelper.updateSubscribedData(id, data, loadResponse as? EpisodeResponse)
val isSubscribed = data != null
_subscribeStatus.postValue(isSubscribed)
}
}
private fun postEpisodeRange(indexer: EpisodeIndexer?, range: EpisodeRange?) {
if (range == null || indexer == null) {
return
@ -1806,6 +1857,7 @@ class ResultViewModel2 : ViewModel() {
) {
currentResponse = loadResponse
postPage(loadResponse, apiRepository)
postSubscription(loadResponse)
if (updateEpisodes)
postEpisodes(loadResponse, updateFillers)
}

View File

@ -4,6 +4,8 @@ import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Activity.RESULT_CANCELED
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.*
import android.content.pm.PackageManager
import android.database.Cursor
@ -196,6 +198,22 @@ object AppUtils {
animation.start()
}
fun Context.createNotificationChannel(channelId: String, channelName: String, description: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel =
NotificationChannel(channelId, channelName, importance).apply {
this.description = description
}
// Register the channel with the system.
val notificationManager: NotificationManager =
this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
@SuppressLint("RestrictedApi")
fun getAllWatchNextPrograms(context: Context): Set<Long> {
val COLUMN_WATCH_NEXT_ID_INDEX = 0

View File

@ -1,16 +1,13 @@
package com.lagradost.cloudstream3.utils
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder.capitalize
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.SearchQuality
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.WatchType
@ -20,6 +17,7 @@ const val VIDEO_POS_DUR = "video_pos_dur"
const val VIDEO_WATCH_STATE = "video_watch_state"
const val RESULT_WATCH_STATE = "result_watch_state"
const val RESULT_WATCH_STATE_DATA = "result_watch_state_data"
const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data"
const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes
const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching"
const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated"
@ -42,6 +40,37 @@ object DataStoreHelper {
return this
}
/**
* Used to display notifications on new episodes and posters in library.
**/
data class SubscribedData(
@JsonProperty("id") override var id: Int?,
@JsonProperty("subscribedTime") val bookmarkedTime: Long,
@JsonProperty("latestUpdatedTime") val latestUpdatedTime: Long,
@JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map<DubStatus, Int?>,
@JsonProperty("name") override val name: String,
@JsonProperty("url") override val url: String,
@JsonProperty("apiName") override val apiName: String,
@JsonProperty("type") override var type: TvType? = null,
@JsonProperty("posterUrl") override var posterUrl: String?,
@JsonProperty("year") val year: Int?,
@JsonProperty("quality") override var quality: SearchQuality? = null,
@JsonProperty("posterHeaders") override var posterHeaders: Map<String, String>? = null,
) : SearchResponse {
fun toLibraryItem(): SyncAPI.LibraryItem? {
return SyncAPI.LibraryItem(
name,
url,
id?.toString() ?: return null,
null,
null,
null,
latestUpdatedTime,
apiName, type, posterUrl, posterHeaders, quality, this.id
)
}
}
data class BookmarkedData(
@JsonProperty("id") override var id: Int?,
@JsonProperty("bookmarkedTime") val bookmarkedTime: Long,
@ -63,7 +92,7 @@ object DataStoreHelper {
null,
null,
null,
null,
latestUpdatedTime,
apiName, type, posterUrl, posterHeaders, quality, this.id
)
}
@ -75,9 +104,7 @@ object DataStoreHelper {
@JsonProperty("apiName") override val apiName: String,
@JsonProperty("type") override var type: TvType? = null,
@JsonProperty("posterUrl") override var posterUrl: String?,
@JsonProperty("watchPos") val watchPos: PosDur?,
@JsonProperty("id") override var id: Int?,
@JsonProperty("parentId") val parentId: Int?,
@JsonProperty("episode") val episode: Int?,
@ -204,6 +231,41 @@ object DataStoreHelper {
return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString())
}
fun getAllSubscriptions(): List<SubscribedData> {
return getKeys("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA")?.mapNotNull {
getKey(it)
} ?: emptyList()
}
fun removeSubscribedData(id: Int?) {
if (id == null) return
AccountManager.localListApi.requireLibraryRefresh = true
removeKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString())
}
/**
* Set new seen episodes and update time
**/
fun updateSubscribedData(id: Int?, data: SubscribedData?, episodeResponse: EpisodeResponse?) {
if (id == null || data == null || episodeResponse == null) return
val newData = data.copy(
latestUpdatedTime = unixTimeMS,
lastSeenEpisodeCount = episodeResponse.getLatestEpisodes()
)
setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), newData)
}
fun setSubscribedData(id: Int?, data: SubscribedData) {
if (id == null) return
setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), data)
AccountManager.localListApi.requireLibraryRefresh = true
}
fun getSubscribedData(id: Int?): SubscribedData? {
if (id == null) return null
return getKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString())
}
fun setViewPos(id: Int?, pos: Long, dur: Long) {
if (id == null) return
if (dur < 30_000) return // too short

View File

@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import kotlinx.coroutines.delay
@ -47,24 +48,12 @@ class PackageInstallerService : Service() {
.setSmallIcon(R.drawable.rdload)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel =
NotificationChannel(UPDATE_CHANNEL_ID, UPDATE_CHANNEL_NAME, importance).apply {
description = UPDATE_CHANNEL_DESCRIPTION
}
// Register the channel with the system
val notificationManager: NotificationManager =
this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
override fun onCreate() {
createNotificationChannel()
this.createNotificationChannel(
UPDATE_CHANNEL_ID,
UPDATE_CHANNEL_NAME,
UPDATE_CHANNEL_DESCRIPTION
)
startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build())
}

View File

@ -20,6 +20,7 @@ import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.bumptech.glide.load.model.GlideUrl
import com.fasterxml.jackson.annotation.JsonProperty
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
@ -213,7 +214,7 @@ object VideoDownloadManager {
}
private val cachedBitmaps = hashMapOf<String, Bitmap>()
private fun Context.getImageBitmapFromUrl(url: String): Bitmap? {
fun Context.getImageBitmapFromUrl(url: String, headers: Map<String, String>? = null): Bitmap? {
try {
if (cachedBitmaps.containsKey(url)) {
return cachedBitmaps[url]
@ -221,12 +222,14 @@ object VideoDownloadManager {
val bitmap = GlideApp.with(this)
.asBitmap()
.load(url).into(720, 720)
.load(GlideUrl(url) { headers ?: emptyMap() })
.into(720, 720)
.get()
if (bitmap != null) {
cachedBitmaps[url] = bitmap
}
return null
return bitmap
} catch (e: Exception) {
logError(e)
return null
@ -426,7 +429,7 @@ object VideoDownloadManager {
}
private const val reservedChars = "|\\?*<\":>+[]/\'"
fun sanitizeFilename(name: String, removeSpaces: Boolean= false): String {
fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String {
var tempName = name
for (c in reservedChars) {
tempName = tempName.replace(c, ' ')
@ -1612,7 +1615,7 @@ object VideoDownloadManager {
.mapIndexed { index, any -> DownloadQueueResumePackage(index, any) }
.toTypedArray()
setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue)
} catch (t : Throwable) {
} catch (t: Throwable) {
logError(t)
}
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="?attr/white"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2zM16,17L8,17v-6c0,-2.48 1.51,-4.5 4,-4.5s4,2.02 4,4.5v6z"/>
</vector>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="108dp"
android:height="108dp"
android:viewportWidth="50"
android:viewportHeight="50"
android:name="vector">
<group android:scaleX="0.1755477"
android:scaleY="0.1755477"
android:translateX="0"
android:translateY="0">
<path android:name="path"
android:pathData="M 245.05 148.63 C 242.249 148.627 239.463 149.052 236.79 149.89 C 235.151 141.364 230.698 133.63 224.147 127.931 C 217.597 122.233 209.321 118.893 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 245.05 203.9 C 252.375 203.9 259.408 200.987 264.587 195.807 C 269.767 190.628 272.68 183.595 272.68 176.27 C 272.68 168.945 269.767 161.912 264.587 156.733 C 259.408 151.553 252.375 148.64 245.05 148.64 Z"
android:fillColor="#FFFFFF" android:strokeWidth="1"
tools:ignore="VectorPath"
android:fillAlpha="0.55"/>
<path android:name="path_1" android:pathData="M 208.61 125 C 208.61 123.22 208.55 121.45 208.48 119.69 C 205.919 119.01 203.296 118.595 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 179 203.9 C 198.116 182.073 208.646 154.015 208.61 125 Z"
android:fillColor="#FFFFFF" android:strokeWidth="1"
android:fillAlpha="0.55"/>
<path android:name="path_2" android:pathData="M 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.783 148.665 23.909 151.471 18.779 156.461 C 13.648 161.452 10.653 168.246 10.43 175.399 C 10.207 182.553 12.773 189.52 17.583 194.82 C 22.392 200.121 29.079 203.349 36.22 203.82 C 67.216 202.93 96.673 189.98 118.284 167.742 C 139.895 145.504 151.997 115.689 152 84.68 C 152 83 151.94 81.33 151.87 79.68 C 149.443 79.361 146.998 79.194 144.55 79.18 C 136.095 79.171 127.735 80.962 120.026 84.434 C 112.317 87.907 105.435 92.982 99.84 99.32 Z"
android:fillColor="#FFFFFF" android:strokeWidth="1"
android:fillAlpha="1"/>
</group>
</vector>

View File

@ -57,6 +57,7 @@
android:layout_width="match_parent"
android:layout_height="50dp"
android:id="@+id/media_route_button_holder"
android:animateLayoutChanges="true"
android:layout_gravity="center_vertical|end">
<androidx.mediarouter.app.MediaRouteButton
@ -69,15 +70,35 @@
app:mediaRouteButtonTint="?attr/textColor" />
<ImageView
android:visibility="gone"
android:nextFocusUp="@id/result_back"
android:nextFocusDown="@id/result_description"
android:nextFocusLeft="@id/result_add_sync"
android:nextFocusRight="@id/result_share"
tools:visibility="visible"
android:id="@+id/result_subscribe"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_margin="5dp"
android:elevation="10dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:src="@drawable/baseline_notifications_none_24"
android:layout_gravity="end|center_vertical"
app:tint="?attr/textColor" />
<ImageView
android:nextFocusUp="@id/result_back"
android:nextFocusDown="@id/result_description"
android:nextFocusLeft="@id/result_subscribe"
android:nextFocusRight="@id/result_open_in_browser"
android:id="@+id/result_share"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginEnd="10dp"
android:layout_margin="5dp"
android:elevation="10dp"
android:background="?android:attr/selectableItemBackgroundBorderless"

View File

@ -644,4 +644,9 @@
<string name="empty_library_no_accounts_message">Looks like your library is empty :(\nLogin to a library account or add shows to your local library</string>
<string name="empty_library_logged_in_message">Looks like this list is empty, try switching to another one</string>
<string name="safe_mode_file">Safe mode file found!\nNot loading any extensions on startup until file is removed.</string>
<string name="subscription_in_progress_notification">Updating subscribed shows</string>
<string name="subscription_list_name">Subscribed</string>
<string name="subscription_new">Subscribed to %s</string>
<string name="subscription_deleted">Unsubscribed from %s</string>
<string name="subscription_episode_released">Episode %d released!</string>
</resources>