Add SubScene (#923)

* Lower targetSdk to get all installed packages

* Update sdk version

* Let's not be too radical

* Many fixes

* Revert targetSdk

* Make account homepage persistent

* Add SubScene and change subtitle API

Co-authored-by: Aymanbest <51868001+aymanbest@users.noreply.github.com>

* Fix file deletion

---------

Co-authored-by: Aymanbest <51868001+aymanbest@users.noreply.github.com>
This commit is contained in:
CranberrySoup 2024-02-06 22:27:35 +00:00 committed by GitHub
parent 9ea7674a0f
commit 2b7d102716
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 333 additions and 65 deletions

View file

@ -61,6 +61,7 @@ import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.initAll import com.lagradost.cloudstream3.APIHolder.initAll
import com.lagradost.cloudstream3.APIHolder.updateHasTrailers import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
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
import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.loadThemes
@ -287,9 +288,27 @@ var app = Requests(responseParser = object : ResponseParser {
class MainActivity : AppCompatActivity(), ColorPickerDialogListener { class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
companion object { companion object {
const val TAG = "MAINACT" const val TAG = "MAINACT"
const val ANIMATED_OUTLINE : Boolean = false const val ANIMATED_OUTLINE: Boolean = false
var lastError: String? = null var lastError: String? = null
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
/**
* Transient files to delete on application exit.
* Deletes files on onDestroy().
*/
private var filesToDelete: Set<String>
// This needs to be persistent because the application may exit without calling onDestroy.
get() = getKey<Set<String>>(FILE_DELETE_KEY) ?: setOf()
private set(value) = setKey(FILE_DELETE_KEY, value)
/**
* Add file to delete on Exit.
*/
fun deleteFileOnExit(file: File) {
filesToDelete = filesToDelete + file.path
}
/** /**
* Setting this will automatically enter the query in the search * Setting this will automatically enter the query in the search
* next time the search fragment is opened. * next time the search fragment is opened.
@ -676,6 +695,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
} }
override fun onDestroy() { override fun onDestroy() {
filesToDelete.forEach { path ->
val result = File(path).deleteRecursively()
if (result) {
Log.d(TAG, "Deleted temporary file: $path")
} else {
Log.d(TAG, "Failed to delete temporary file: $path")
}
}
filesToDelete = setOf()
val broadcastIntent = Intent() val broadcastIntent = Intent()
broadcastIntent.action = "restart_service" broadcastIntent.action = "restart_service"
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
@ -1654,7 +1682,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
// this ensures that no unnecessary space is taken // this ensures that no unnecessary space is taken
loadCache() loadCache()
File(filesDir, "exoplayer").deleteRecursively() // old cache File(filesDir, "exoplayer").deleteRecursively() // old cache
File(cacheDir, "exoplayer").deleteOnExit() // current cache deleteFileOnExit(File(cacheDir, "exoplayer")) // current cache
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }

View file

@ -1,11 +1,23 @@
package com.lagradost.cloudstream3.subtitles package com.lagradost.cloudstream3.subtitles
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.net.toUri
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
import okio.BufferedSource
import okio.buffer
import okio.sink
import okio.source
import java.io.File
import java.util.zip.ZipInputStream
interface AbstractSubProvider { interface AbstractSubProvider {
val idPrefix: String
@WorkerThread @WorkerThread
suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? { suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? {
throw NotImplementedError() throw NotImplementedError()
@ -15,6 +27,98 @@ interface AbstractSubProvider {
suspend fun load(data: SubtitleEntity): String? { suspend fun load(data: SubtitleEntity): String? {
throw NotImplementedError() throw NotImplementedError()
} }
@WorkerThread
suspend fun SubtitleResource.getResources(data: SubtitleEntity) {
this.addUrl(load(data))
}
@WorkerThread
suspend fun getResource(data: SubtitleEntity): SubtitleResource {
return SubtitleResource().apply {
this.getResources(data)
}
}
}
/**
* A builder for subtitle files.
* @see addUrl
* @see addFile
*/
class SubtitleResource {
fun downloadFile(source: BufferedSource): File {
val file = File.createTempFile("temp-subtitle", ".tmp").apply {
deleteFileOnExit(this)
}
val sink = file.sink().buffer()
sink.writeAll(source)
sink.close()
source.close()
return file
}
fun unzip(file: File): List<Pair<String, File>> {
val entries = mutableListOf<Pair<String, File>>()
ZipInputStream(file.inputStream()).use { zipInputStream ->
var zipEntry = zipInputStream.nextEntry
while (zipEntry != null) {
val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply {
deleteFileOnExit(this)
}
entries.add(zipEntry.name to tempFile)
tempFile.sink().buffer().use { buffer ->
buffer.writeAll(zipInputStream.source())
}
zipEntry = zipInputStream.nextEntry
}
}
return entries
}
data class SingleSubtitleResource(
val name: String?,
val url: String,
val origin: SubtitleOrigin
)
private var resources: MutableList<SingleSubtitleResource> = mutableListOf()
fun getSubtitles(): List<SingleSubtitleResource> {
return resources.toList()
}
fun addUrl(url: String?, name: String? = null) {
if (url == null) return
this.resources.add(
SingleSubtitleResource(name, url, SubtitleOrigin.URL)
)
}
fun addFile(file: File, name: String? = null) {
this.resources.add(
SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE)
)
deleteFileOnExit(file)
}
suspend fun addZipUrl(
url: String,
nameGenerator: (String, File) -> String? = { _, _ -> null }
) {
val source = app.get(url).okhttpResponse.body.source()
val zip = downloadFile(source)
val realFiles = unzip(zip)
zip.deleteRecursively()
realFiles.forEach { (name, subtitleFile) ->
addFile(subtitleFile, nameGenerator(name, subtitleFile))
}
}
} }
interface AbstractSubApi : AbstractSubProvider, AuthAPI interface AbstractSubApi : AbstractSubProvider, AuthAPI

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
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.syncproviders.providers.SubScene
import com.lagradost.cloudstream3.syncproviders.providers.* import com.lagradost.cloudstream3.syncproviders.providers.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -14,6 +15,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
val simklApi = SimklApi(0) val simklApi = SimklApi(0)
val indexSubtitlesApi = IndexSubtitleApi() val indexSubtitlesApi = IndexSubtitleApi()
val addic7ed = Addic7ed() val addic7ed = Addic7ed()
val subScene = SubScene()
val localListApi = LocalList() val localListApi = LocalList()
// used to login via app intent // used to login via app intent
@ -41,7 +43,8 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
get() = listOf( get() = listOf(
openSubtitlesApi, openSubtitlesApi,
indexSubtitlesApi, // they got anti scraping measures in place :( indexSubtitlesApi, // they got anti scraping measures in place :(
addic7ed addic7ed,
subScene
) )
const val appString = "cloudstreamapp" const val appString = "cloudstreamapp"

View file

@ -23,28 +23,8 @@ class IndexSubtitleApi : AbstractSubApi {
companion object { companion object {
const val host = "https://indexsubtitle.com" const val host = "https://indexsubtitle.com"
const val TAG = "INDEXSUBS" const val TAG = "INDEXSUBS"
}
private fun fixUrl(url: String): String { fun getOrdinal(num: Int?): String? {
if (url.startsWith("http")) {
return url
}
if (url.isEmpty()) {
return ""
}
val startsWithNoHttp = url.startsWith("//")
if (startsWithNoHttp) {
return "https:$url"
} else {
if (url.startsWith('/')) {
return host + url
}
return "$host/$url"
}
}
private fun getOrdinal(num: Int?): String? {
return when (num) { return when (num) {
1 -> "First" 1 -> "First"
2 -> "Second" 2 -> "Second"
@ -84,6 +64,26 @@ class IndexSubtitleApi : AbstractSubApi {
else -> null else -> null
} }
} }
}
private fun fixUrl(url: String): String {
if (url.startsWith("http")) {
return url
}
if (url.isEmpty()) {
return ""
}
val startsWithNoHttp = url.startsWith("//")
if (startsWithNoHttp) {
return "https:$url"
} else {
if (url.startsWith('/')) {
return host + url
}
return "$host/$url"
}
}
private fun isRightEps(text: String, seasonNum: Int?, epNum: Int?): Boolean { private fun isRightEps(text: String, seasonNum: Int?, epNum: Int?): Boolean {
val FILTER_EPS_REGEX = val FILTER_EPS_REGEX =

View file

@ -0,0 +1,118 @@
package com.lagradost.cloudstream3.syncproviders.providers
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.subtitles.AbstractSubProvider
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.syncproviders.providers.IndexSubtitleApi.Companion.getOrdinal
import com.lagradost.cloudstream3.utils.SubtitleHelper
class SubScene : AbstractSubProvider {
val mainUrl = "https://subscene.com"
val name = "Subscene"
override val idPrefix = "subscene"
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
val seasonName =
query.seasonNumber?.let { number ->
// Need to translate "7" to "Seventh Season"
getOrdinal(number)?.let { words -> " - $words Season" }
} ?: ""
val fullQuery = query.query + seasonName
val doc = app.post(
"$mainUrl/subtitles/searchbytitle",
data = mapOf("query" to fullQuery, "l" to "")
).document
return doc.select("div.title a").map { element ->
val href = "$mainUrl${element.attr("href")}"
val title = element.text()
AbstractSubtitleEntities.SubtitleEntity(
idPrefix = idPrefix,
name = title,
source = name,
data = href,
lang = query.lang ?: "en",
epNumber = query.epNumber
)
}.distinctBy { it.data }
}
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
val resultDoc = app.get(data.data).document
val queryLanguage = SubtitleHelper.fromTwoLettersToLanguage(data.lang) ?: "English"
val results = resultDoc.select("table tbody tr").mapNotNull { element ->
val anchor = element.select("a")
val href = anchor.attr("href") ?: return@mapNotNull null
val fixedHref = "$mainUrl${href}"
val spans = anchor.select("span")
val language = spans.firstOrNull()?.text()
val title = spans.getOrNull(1)?.text()
val isPositive = anchor.select("span.positive-icon").isNotEmpty()
TableElement(title, language, fixedHref, isPositive)
}.sortedBy {
it.getScore(queryLanguage, data.epNumber)
}
debugPrint { "$name found subtitles: ${results.takeLast(3)}" }
// Last = highest score
val selectedResult = results.lastOrNull() ?: return
val subtitleDocument = app.get(selectedResult.href).document
val subtitleDownloadUrl =
"$mainUrl${subtitleDocument.select("div.download a").attr("href")}"
this.addZipUrl(subtitleDownloadUrl) { name, _ ->
name
}
}
/**
* Class to manage the various different subtitle results and rank them.
*/
data class TableElement(
val title: String?,
val language: String?,
val href: String,
val isPositive: Boolean
) {
private fun matchesLanguage(other: String): Boolean {
return language != null && (language.contains(other, ignoreCase = true) ||
other.contains(language, ignoreCase = true))
}
/**
* Scores in this order:
* Preferred Language > Episode number > Positive rating > English Language
*/
fun getScore(queryLanguage: String, episodeNum: Int?): Int {
var score = 0
if (this.matchesLanguage(queryLanguage)) {
score += 8
}
// Matches Episode 7 using "E07" with any number of leading zeroes
if (episodeNum != null && title != null && title.contains(
Regex(
"""E0*${episodeNum}""",
RegexOption.IGNORE_CASE
)
)
) {
score += 4
}
if (isPositive) {
score += 2
}
if (this.matchesLanguage("English")) {
score += 1
}
return score
}
}
}

View file

@ -50,6 +50,7 @@ import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugAssert
@ -657,7 +658,7 @@ class CS3IPlayer : IPlayer {
SimpleCache( SimpleCache(
File( File(
context.cacheDir, "exoplayer" context.cacheDir, "exoplayer"
).also { it.deleteOnExit() }, // Ensures always fresh file ).also { deleteFileOnExit(it) }, // Ensures always fresh file
LeastRecentlyUsedCacheEvictor(cacheSize), LeastRecentlyUsedCacheEvictor(cacheSize),
databaseProvider databaseProvider
) )

View file

@ -30,6 +30,7 @@ import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding
import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.mvvm.*
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders
import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage
@ -69,7 +70,10 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
val subsProviders val subsProviders
get() = subtitleProviders.filter { !it.requiresLogin || it.loginInfo() != null } get() = subtitleProviders.filter { provider ->
(provider as? AbstractSubApi)?.let { !it.requiresLogin || it.loginInfo() != null }
?: true
}
val subsProvidersIsActive val subsProvidersIsActive
get() = subsProviders.isNotEmpty() get() = subsProviders.isNotEmpty()
} }
@ -147,7 +151,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
override fun playerStatusChanged() { override fun playerStatusChanged() {
if(player.getIsPlaying()){ if (player.getIsPlaying()) {
viewModel.forceClearCache = false viewModel.forceClearCache = false
} }
} }
@ -473,17 +477,21 @@ class GeneratorPlayer : FullScreenPlayer() {
currentSubtitle?.let { currentSubtitle -> currentSubtitle?.let { currentSubtitle ->
providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api ->
ioSafe { ioSafe {
val url = api.load(currentSubtitle) ?: return@ioSafe val subtitles =
val subtitle = SubtitleData( api.getResource(currentSubtitle).getSubtitles().map { resource ->
name = getName(currentSubtitle, true), SubtitleData(
url = url, name = resource.name ?: getName(currentSubtitle, true),
origin = SubtitleOrigin.URL, url = resource.url,
mimeType = url.toSubtitleMimeType(), origin = resource.origin,
mimeType = resource.url.toSubtitleMimeType(),
headers = currentSubtitle.headers, headers = currentSubtitle.headers,
currentSubtitle.lang currentSubtitle.lang
) )
}
if (subtitles.isNotEmpty()) {
runOnMainThread { runOnMainThread {
addAndSelectSubtitles(subtitle) addAndSelectSubtitles(*subtitles.toTypedArray())
}
} }
} }
} }
@ -521,7 +529,11 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
} }
private fun addAndSelectSubtitles(subtitleData: SubtitleData) { private fun addAndSelectSubtitles(
vararg subtitleData: SubtitleData
) {
if (subtitleData.isEmpty()) return
val selectedSubtitle = subtitleData.first()
val ctx = context ?: return val ctx = context ?: return
val subs = currentSubs + subtitleData val subs = currentSubs + subtitleData
@ -533,13 +545,13 @@ class GeneratorPlayer : FullScreenPlayer() {
player.saveData() player.saveData()
player.reloadPlayer(ctx) player.reloadPlayer(ctx)
setSubtitles(subtitleData) setSubtitles(selectedSubtitle)
viewModel.addSubtitles(setOf(subtitleData)) viewModel.addSubtitles(subtitleData.toSet())
selectSourceDialog?.dismissSafe() selectSourceDialog?.dismissSafe()
showToast( showToast(
String.format(ctx.getString(R.string.player_loaded_subtitles), subtitleData.name), String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name),
Toast.LENGTH_LONG Toast.LENGTH_LONG
) )
} }
@ -919,7 +931,7 @@ class GeneratorPlayer : FullScreenPlayer() {
override fun playerError(exception: Throwable) { override fun playerError(exception: Throwable) {
Log.i(TAG, "playerError = $currentSelectedLink") Log.i(TAG, "playerError = $currentSelectedLink")
if(!hasNextMirror()){ if (!hasNextMirror()) {
viewModel.forceClearCache = true viewModel.forceClearCache = true
} }
super.playerError(exception) super.playerError(exception)

View file

@ -23,6 +23,7 @@ import okio.buffer
import okio.sink import okio.sink
import java.io.File import java.io.File
import android.text.TextUtils import android.text.TextUtils
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
import java.io.BufferedReader import java.io.BufferedReader
import java.io.IOException import java.io.IOException
@ -213,7 +214,7 @@ class InAppUpdater {
this.cacheDir.listFiles()?.filter { this.cacheDir.listFiles()?.filter {
it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix
}?.forEach { }?.forEach {
it.deleteOnExit() deleteFileOnExit(it)
} }
val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix")

View file

@ -12,6 +12,7 @@ import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
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.AppUtils.createNotificationChannel
@ -75,7 +76,7 @@ class PackageInstallerService : Service() {
this@PackageInstallerService.cacheDir.listFiles()?.filter { this@PackageInstallerService.cacheDir.listFiles()?.filter {
it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix
}?.forEach { }?.forEach {
it.deleteOnExit() deleteFileOnExit(it)
} }
} }