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") //implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
// Downloading // Downloading
implementation("androidx.work:work-runtime:2.7.1") implementation("androidx.work:work-runtime:2.8.0")
implementation("androidx.work:work-runtime-ktx:2.7.1") implementation("androidx.work:work-runtime-ktx:2.8.0")
// Networking // Networking
// implementation("com.squareup.okhttp3:okhttp:4.9.2") // implementation("com.squareup.okhttp3:okhttp:4.9.2")

View File

@ -1327,7 +1327,7 @@ fun LoadResponse?.isAnimeBased(): Boolean {
fun TvType?.isEpisodeBased(): Boolean { fun TvType?.isEpisodeBased(): Boolean {
if (this == null) return false 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 showStatus: ShowStatus?
var nextAiring: NextAiring? var nextAiring: NextAiring?
var seasonNames: List<SeasonData>? var seasonNames: List<SeasonData>?
fun getLatestEpisodes(): Map<DubStatus, Int?>
} }
@JvmName("addSeasonNamesString") @JvmName("addSeasonNamesString")
@ -1419,7 +1420,18 @@ data class AnimeLoadResponse(
override var nextAiring: NextAiring? = null, override var nextAiring: NextAiring? = null,
override var seasonNames: List<SeasonData>? = null, override var seasonNames: List<SeasonData>? = null,
override var backgroundPosterUrl: String? = 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. * If episodes already exist appends the list.
@ -1617,7 +1629,17 @@ data class TvSeriesLoadResponse(
override var nextAiring: NextAiring? = null, override var nextAiring: NextAiring? = null,
override var seasonNames: List<SeasonData>? = null, override var seasonNames: List<SeasonData>? = null,
override var backgroundPosterUrl: String? = 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( suspend fun MainAPI.newTvSeriesLoadResponse(
name: String, name: String,

View File

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

View File

@ -15,6 +15,7 @@ import android.view.ViewGroup
import android.widget.AbsListView import android.widget.AbsListView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.ImageView import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible 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.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.APIHolder.updateHasTrailers import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.mvvm.*
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
@ -850,7 +853,7 @@ open class ResultFragment : ResultTrailerPlayer() {
} }
observe(viewModel.page) { data -> observe(viewModel.page) { data ->
if(data == null) return@observe if (data == null) return@observe
when (data) { when (data) {
is Resource.Success -> { is Resource.Success -> {
val d = data.value val d = data.value
@ -904,6 +907,36 @@ open class ResultFragment : ResultTrailerPlayer() {
updateList(d.actors ?: emptyList()) 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?.isVisible = d.url.startsWith("http")
result_open_in_browser?.setOnClickListener { result_open_in_browser?.setOnClickListener {
val i = Intent(ACTION_VIEW) 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.apis
import com.lagradost.cloudstream3.APIHolder.getId import com.lagradost.cloudstream3.APIHolder.getId
import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.getCastSession
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
@ -414,6 +415,9 @@ class ResultViewModel2 : ViewModel() {
private val _episodeSynopsis: MutableLiveData<String?> = MutableLiveData(null) private val _episodeSynopsis: MutableLiveData<String?> = MutableLiveData(null)
val episodeSynopsis: LiveData<String?> = _episodeSynopsis val episodeSynopsis: LiveData<String?> = _episodeSynopsis
private val _subscribeStatus: MutableLiveData<Boolean?> = MutableLiveData(null)
val subscribeStatus: LiveData<Boolean?> = _subscribeStatus
companion object { companion object {
const val TAG = "RVM2" const val TAG = "RVM2"
private const val EPISODE_RANGE_SIZE = 20 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( private fun startChromecast(
activity: Activity?, activity: Activity?,
result: ResultEpisode, result: ResultEpisode,
@ -1473,7 +1513,8 @@ class ResultViewModel2 : ViewModel() {
this.engName, this.engName,
this.name, this.name,
this.japName 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), TrackerType.getTypes(this.type),
this.year this.year
) )
@ -1670,6 +1711,16 @@ class ResultViewModel2 : ViewModel() {
postResume() 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?) { private fun postEpisodeRange(indexer: EpisodeIndexer?, range: EpisodeRange?) {
if (range == null || indexer == null) { if (range == null || indexer == null) {
return return
@ -1806,6 +1857,7 @@ class ResultViewModel2 : ViewModel() {
) { ) {
currentResponse = loadResponse currentResponse = loadResponse
postPage(loadResponse, apiRepository) postPage(loadResponse, apiRepository)
postSubscription(loadResponse)
if (updateEpisodes) if (updateEpisodes)
postEpisodes(loadResponse, updateFillers) postEpisodes(loadResponse, updateFillers)
} }

View File

@ -4,6 +4,8 @@ import android.animation.ObjectAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_CANCELED
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.* import android.content.*
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.database.Cursor import android.database.Cursor
@ -196,6 +198,22 @@ object AppUtils {
animation.start() 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") @SuppressLint("RestrictedApi")
fun getAllWatchNextPrograms(context: Context): Set<Long> { fun getAllWatchNextPrograms(context: Context): Set<Long> {
val COLUMN_WATCH_NEXT_ID_INDEX = 0 val COLUMN_WATCH_NEXT_ID_INDEX = 0

View File

@ -1,16 +1,13 @@
package com.lagradost.cloudstream3.utils package com.lagradost.cloudstream3.utils
import com.fasterxml.jackson.annotation.JsonProperty 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.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey 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.AccountManager
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.WatchType 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 VIDEO_WATCH_STATE = "video_watch_state"
const val RESULT_WATCH_STATE = "result_watch_state" const val RESULT_WATCH_STATE = "result_watch_state"
const val RESULT_WATCH_STATE_DATA = "result_watch_state_data" 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 = "result_resume_watching_2" // changed due to id changes
const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching" const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching"
const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated" const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated"
@ -42,6 +40,37 @@ object DataStoreHelper {
return this 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( data class BookmarkedData(
@JsonProperty("id") override var id: Int?, @JsonProperty("id") override var id: Int?,
@JsonProperty("bookmarkedTime") val bookmarkedTime: Long, @JsonProperty("bookmarkedTime") val bookmarkedTime: Long,
@ -63,7 +92,7 @@ object DataStoreHelper {
null, null,
null, null,
null, null,
null, latestUpdatedTime,
apiName, type, posterUrl, posterHeaders, quality, this.id apiName, type, posterUrl, posterHeaders, quality, this.id
) )
} }
@ -75,9 +104,7 @@ object DataStoreHelper {
@JsonProperty("apiName") override val apiName: String, @JsonProperty("apiName") override val apiName: String,
@JsonProperty("type") override var type: TvType? = null, @JsonProperty("type") override var type: TvType? = null,
@JsonProperty("posterUrl") override var posterUrl: String?, @JsonProperty("posterUrl") override var posterUrl: String?,
@JsonProperty("watchPos") val watchPos: PosDur?, @JsonProperty("watchPos") val watchPos: PosDur?,
@JsonProperty("id") override var id: Int?, @JsonProperty("id") override var id: Int?,
@JsonProperty("parentId") val parentId: Int?, @JsonProperty("parentId") val parentId: Int?,
@JsonProperty("episode") val episode: Int?, @JsonProperty("episode") val episode: Int?,
@ -204,6 +231,41 @@ object DataStoreHelper {
return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) 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) { fun setViewPos(id: Int?, pos: Long, dur: Long) {
if (id == null) return if (id == null) return
if (dur < 30_000) return // too short 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.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -47,24 +48,12 @@ class PackageInstallerService : Service() {
.setSmallIcon(R.drawable.rdload) .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() { override fun onCreate() {
createNotificationChannel() this.createNotificationChannel(
UPDATE_CHANNEL_ID,
UPDATE_CHANNEL_NAME,
UPDATE_CHANNEL_DESCRIPTION
)
startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build())
} }

View File

@ -20,6 +20,7 @@ import androidx.work.Data
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.bumptech.glide.load.model.GlideUrl
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
@ -213,7 +214,7 @@ object VideoDownloadManager {
} }
private val cachedBitmaps = hashMapOf<String, Bitmap>() 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 { try {
if (cachedBitmaps.containsKey(url)) { if (cachedBitmaps.containsKey(url)) {
return cachedBitmaps[url] return cachedBitmaps[url]
@ -221,12 +222,14 @@ object VideoDownloadManager {
val bitmap = GlideApp.with(this) val bitmap = GlideApp.with(this)
.asBitmap() .asBitmap()
.load(url).into(720, 720) .load(GlideUrl(url) { headers ?: emptyMap() })
.into(720, 720)
.get() .get()
if (bitmap != null) { if (bitmap != null) {
cachedBitmaps[url] = bitmap cachedBitmaps[url] = bitmap
} }
return null return bitmap
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
return null return null
@ -426,7 +429,7 @@ object VideoDownloadManager {
} }
private const val reservedChars = "|\\?*<\":>+[]/\'" private const val reservedChars = "|\\?*<\":>+[]/\'"
fun sanitizeFilename(name: String, removeSpaces: Boolean= false): String { fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String {
var tempName = name var tempName = name
for (c in reservedChars) { for (c in reservedChars) {
tempName = tempName.replace(c, ' ') tempName = tempName.replace(c, ' ')
@ -1612,7 +1615,7 @@ object VideoDownloadManager {
.mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) }
.toTypedArray() .toTypedArray()
setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue)
} catch (t : Throwable) { } catch (t: Throwable) {
logError(t) 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_width="match_parent"
android:layout_height="50dp" android:layout_height="50dp"
android:id="@+id/media_route_button_holder" android:id="@+id/media_route_button_holder"
android:animateLayoutChanges="true"
android:layout_gravity="center_vertical|end"> android:layout_gravity="center_vertical|end">
<androidx.mediarouter.app.MediaRouteButton <androidx.mediarouter.app.MediaRouteButton
@ -69,15 +70,35 @@
app:mediaRouteButtonTint="?attr/textColor" /> app:mediaRouteButtonTint="?attr/textColor" />
<ImageView <ImageView
android:visibility="gone"
android:nextFocusUp="@id/result_back" android:nextFocusUp="@id/result_back"
android:nextFocusDown="@id/result_description" android:nextFocusDown="@id/result_description"
android:nextFocusLeft="@id/result_add_sync" 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:nextFocusRight="@id/result_open_in_browser"
android:id="@+id/result_share" android:id="@+id/result_share"
android:layout_width="25dp" android:layout_width="25dp"
android:layout_height="25dp" android:layout_height="25dp"
android:layout_marginEnd="10dp" android:layout_margin="5dp"
android:elevation="10dp" android:elevation="10dp"
android:background="?android:attr/selectableItemBackgroundBorderless" 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_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="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="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> </resources>