From 4e01d327c6dcd5c4b95548d3e7941dfb10c49e42 Mon Sep 17 00:00:00 2001 From: self-similarity <137652432+self-similarity@users.noreply.github.com> Date: Tue, 15 Aug 2023 18:37:33 +0000 Subject: [PATCH 01/39] Fix episode removal in simkl (#555) --- .../syncproviders/providers/SimklApi.kt | 75 ++++++++++++------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index 64cebfc6..b4a9d789 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -60,8 +60,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { private var lastScoreTime = -1L companion object { - private const val clientId = BuildConfig.SIMKL_CLIENT_ID - private const val clientSecret = BuildConfig.SIMKL_CLIENT_SECRET + private const val clientId: String = BuildConfig.SIMKL_CLIENT_ID + private const val clientSecret: String = BuildConfig.SIMKL_CLIENT_SECRET private var lastLoginState = "" const val SIMKL_TOKEN_KEY: String = "simkl_token" @@ -498,6 +498,9 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { val episodes: Array?, override var isFavorite: Boolean? = null, override var maxEpisodes: Int? = null, + /** Save seen episodes separately to know the change from old to new. + * Required to remove seen episodes if count decreases */ + val oldEpisodes: Int, ) : SyncAPI.AbstractSyncStatus() override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { @@ -521,7 +524,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { score = foundItem.user_rating, watchedEpisodes = foundItem.watched_episodes_count, maxEpisodes = foundItem.total_episodes_count, - episodes = episodes + episodes = episodes, + oldEpisodes = foundItem.watched_episodes_count ?: 0, ) } else { return if (searchResult != null) { @@ -530,7 +534,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { score = 0, watchedEpisodes = 0, maxEpisodes = if (searchResult.type == "movie") 0 else null, - episodes = episodes + episodes = episodes, + oldEpisodes = 0, ) } else { null @@ -604,32 +609,46 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { SimklListStatusType.ReWatching.value ).contains(status.status) ) { - val cutEpisodes = simklStatus.episodes.take(watchedEpisodes) - - val (seasons, episodes) = if (cutEpisodes.any { it.season != null }) { - EpisodeMetadata.convertToSeasons(cutEpisodes) to null - } else { - null to EpisodeMetadata.convertToEpisodes(cutEpisodes) + suspend fun postEpisodes( + url: String, + rawEpisodes: List + ): Boolean { + val (seasons, episodes) = if (rawEpisodes.any { it.season != null }) { + EpisodeMetadata.convertToSeasons(rawEpisodes) to null + } else { + null to EpisodeMetadata.convertToEpisodes(rawEpisodes) + } + debugPrint { "Synced history using $url: seasons=${seasons?.toList()}, episodes=${episodes?.toList()}" } + return app.post( + url, + json = StatusRequest( + shows = listOf( + HistoryMediaObject( + null, + null, + MediaObject.Ids.fromMap(parsedId), + seasons, + episodes + ) + ), + movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful } - debugPrint { "Synced history for ${status.watchedEpisodes} given size of ${simklStatus.episodes.size}: seasons=${seasons?.toList()}, episodes=${episodes?.toList()}" } - val episodeResponse = app.post( - "$mainUrl/sync/history", - json = StatusRequest( - shows = listOf( - HistoryMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - seasons, - episodes - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ) - episodeResponse.isSuccessful + // If episodes decrease: remove all episodes beyond watched episodes. + val removeResponse = if (simklStatus.oldEpisodes > watchedEpisodes) { + val removeEpisodes = simklStatus.episodes + .drop(watchedEpisodes) + postEpisodes("$mainUrl/sync/history/remove", removeEpisodes) + } else { + true + } + val cutEpisodes = simklStatus.episodes.take(watchedEpisodes) + val addResponse = postEpisodes("$mainUrl/sync/history/", cutEpisodes) + + removeResponse && addResponse } else true val newStatus = From d536dffaf559425bdb7b02232c3bfac8a951133d Mon Sep 17 00:00:00 2001 From: IndusAryan <125901294+IndusAryan@users.noreply.github.com> Date: Wed, 16 Aug 2023 19:45:39 +0530 Subject: [PATCH 02/39] Fix Trailers not Working (#559) * Fix Trailers not Working * smol tip --- app/build.gradle.kts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c12652a..b228fea0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -165,7 +165,7 @@ dependencies { androidTestImplementation("androidx.test:core") //implementation("io.karn:khttp-android:0.1.2") //okhttp instead -// implementation("org.jsoup:jsoup:1.13.1") + // implementation("org.jsoup:jsoup:1.13.1") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") implementation("androidx.preference:preference-ktx:1.2.0") @@ -220,8 +220,8 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.8.1") // Networking -// implementation("com.squareup.okhttp3:okhttp:4.9.2") -// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1") + // implementation("com.squareup.okhttp3:okhttp:4.9.2") + // implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1") implementation("com.github.Blatzar:NiceHttp:0.4.3") // To fix SSL fuckery on android 9 implementation("org.conscrypt:conscrypt-android:2.2.1") @@ -243,11 +243,9 @@ dependencies { // used for subtitle decoding https://github.com/albfernandez/juniversalchardet implementation("com.github.albfernandez:juniversalchardet:2.4.0") - // slow af yt - //implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT") - // newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204 - implementation("com.github.TeamNewPipe:NewPipeExtractor:8495ad619e") + // this should be updated frequently to avoid trailer fu*kery + implementation("com.github.TeamNewPipe:NewPipeExtractor:1f08d28") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") // Library/extensions searching with Levenshtein distance From d247640dcf2a3985390cb0e3d5fd4c8d82e7d4ad Mon Sep 17 00:00:00 2001 From: IndusAryan <125901294+IndusAryan@users.noreply.github.com> Date: Wed, 16 Aug 2023 19:48:15 +0530 Subject: [PATCH 03/39] Play n Dowload button fix for NS*W results. (#557) * Play n Dowload button fix for NS*W results. * Revert MainAPI Changes * Tweaked ResultViewModel --- .../com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 011d133d..2fe3b012 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -1707,7 +1707,7 @@ class ResultViewModel2 : ViewModel() { else -> { if (response.type.isLiveStream()) R.string.play_livestream_button - else if (response.type.isMovieType()) // this wont break compatibility as you only need to override isMovieType + else if (response.isMovie()) // this wont break compatibility as you only need to override isMovieType R.string.play_movie_button else null } @@ -2340,4 +2340,4 @@ class ResultViewModel2 : ViewModel() { } } } -} \ No newline at end of file +} From 20da3807a2cb98b60b22e4ad65a96c037c254c58 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 17 Aug 2023 01:00:43 +0200 Subject: [PATCH 04/39] fixed search query for intent --- .../com/lagradost/cloudstream3/MainActivity.kt | 12 ++++++++---- .../cloudstream3/ui/search/SearchFragment.kt | 15 +++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index a8160d33..15b16078 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -286,7 +286,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { * * This is a very bad solution but I was unable to find a better one. **/ - private var nextSearchQuery: String? = null + var nextSearchQuery: String? = null /** * Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread @@ -362,9 +362,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { loadRepository(url) return true } else if (safeURI(str)?.scheme == appStringSearch) { + val query = str.substringAfter("$appStringSearch://") nextSearchQuery = - URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8") - + try { + URLDecoder.decode(query, "UTF-8") + } catch (t : Throwable) { + logError(t) + query + } // Use both navigation views to support both layouts. // It might be better to use the QuickSearch. activity?.findViewById(R.id.nav_view)?.selectedItemId = @@ -1315,7 +1320,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) { bundle?.apply { this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery) - nextSearchQuery = null } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index 63213eb9..bdf82377 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -84,7 +84,7 @@ class SearchFragment : Fragment() { fun newInstance(query: String): Bundle { return Bundle().apply { - putString(SEARCH_QUERY, query) + if(query.isNotBlank()) putString(SEARCH_QUERY, query) } } } @@ -211,7 +211,7 @@ class SearchFragment : Fragment() { reloadRepos() binding?.apply { - val adapter: RecyclerView.Adapter? = + val adapter: RecyclerView.Adapter = SearchAdapter( ArrayList(), searchAutofitResults, @@ -530,11 +530,18 @@ class SearchFragment : Fragment() { searchMasterRecycler.layoutManager = GridLayoutManager(context, 1) // Automatically search the specified query, this allows the app search to launch from intent - arguments?.getString(SEARCH_QUERY)?.let { query -> + var sq = arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) + if(sq.isNullOrBlank()) { + sq = MainActivity.nextSearchQuery + } + + sq?.let { query -> if (query.isBlank()) return@let mainSearch.setQuery(query, true) // Clear the query as to not make it request the same query every time the page is opened - arguments?.putString(SEARCH_QUERY, null) + arguments?.remove(SEARCH_QUERY) + savedInstanceState?.remove(SEARCH_QUERY) + MainActivity.nextSearchQuery = null } } From c2b951a07866077e05d15e385111d2d74fe7e542 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 17 Aug 2023 01:19:24 +0200 Subject: [PATCH 05/39] fixed #560 lock locks orientation --- .../ui/player/FullScreenPlayer.kt | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 9739b627..0f3c189d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -2,9 +2,11 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ObjectAnimator import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo import android.content.res.ColorStateList +import android.content.res.Configuration import android.content.res.Resources import android.graphics.Color import android.media.AudioManager @@ -16,6 +18,7 @@ import android.util.DisplayMetrics import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent +import android.view.Surface import android.view.View import android.view.ViewGroup import android.view.WindowManager @@ -56,6 +59,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.Vector2 import kotlin.math.* + const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage @@ -292,6 +296,36 @@ open class FullScreenPlayer : AbstractPlayerFragment() { player.getCurrentPreferredSubtitle() == null } + open fun lockOrientation(activity: Activity) { + val display = + (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay + val rotation = display.rotation + val currentOrientation = activity.resources.configuration.orientation + var orientation = 0 + when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> orientation = + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + + Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + //Configuration.ORIENTATION_PORTRAIT -> orientation = + // if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + } + activity.requestedOrientation = orientation + } + + private fun updateOrientation() { + activity?.apply { + if(lockRotation) { + if(isLocked) { + lockOrientation(this) + } + else { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + } + } + } + protected fun enterFullscreen() { if (isFullScreenPlayer) { activity?.hideSystemUI() @@ -301,8 +335,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.window?.attributes = params } } - if (lockRotation) - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + updateOrientation() } protected fun exitFullscreen() { @@ -561,6 +594,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked + updateOrientation() + if (isLocked && isShowing) { playerBinding?.playerHolder?.postDelayed({ if (isLocked && isShowing) { From 590c74111cb945366ebd6a11278f2f9f827043c5 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 17 Aug 2023 23:10:21 +0200 Subject: [PATCH 06/39] fuck it we ball, m3u8 download is now fixed --- .../lagradost/cloudstream3/extractors/Cda.kt | 10 +- .../cloudstream3/utils/M3u8Helper.kt | 282 +++++++++--------- .../utils/VideoDownloadManager.kt | 221 ++++++-------- 3 files changed, 237 insertions(+), 276 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt index 6a2f399d..42f6eddb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt @@ -1,13 +1,11 @@ package com.lagradost.cloudstream3.extractors -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import android.util.Log +import com.lagradost.cloudstream3.utils.Qualities import java.net.URLDecoder open class Cda: ExtractorApi() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt index 6c5117b4..6770e303 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt @@ -1,17 +1,16 @@ package com.lagradost.cloudstream3.utils +import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.delay import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec import kotlin.math.pow - +/** backwards api surface */ class M3u8Helper { companion object { - private val generator = M3u8Helper() suspend fun generateM3u8( source: String, streamUrl: String, @@ -20,34 +19,59 @@ class M3u8Helper { headers: Map = mapOf(), name: String = source ): List { - return generator.m3u8Generation( - M3u8Stream( - streamUrl = streamUrl, - quality = quality, - headers = headers, - ), null - ) - .map { stream -> - ExtractorLink( - source, - name = name, - stream.streamUrl, - referer, - stream.quality ?: Qualities.Unknown.value, - true, - stream.headers, - ) - } + return M3u8Helper2.generateM3u8(source, streamUrl, referer, quality, headers, name) } } + + data class M3u8Stream( + val streamUrl: String, + val quality: Int? = null, + val headers: Map = mapOf() + ) + + suspend fun m3u8Generation(m3u8: M3u8Stream, returnThis: Boolean? = true): List { + return M3u8Helper2.m3u8Generation(m3u8, returnThis) + } +} + +object M3u8Helper2 { + suspend fun generateM3u8( + source: String, + streamUrl: String, + referer: String, + quality: Int? = null, + headers: Map = mapOf(), + name: String = source + ): List { + return m3u8Generation( + M3u8Helper.M3u8Stream( + streamUrl = streamUrl, + quality = quality, + headers = headers, + ), null + ) + .map { stream -> + ExtractorLink( + source, + name = name, + stream.streamUrl, + referer, + stream.quality ?: Qualities.Unknown.value, + true, + stream.headers, + ) + } + } + private val ENCRYPTION_DETECTION_REGEX = Regex("#EXT-X-KEY:METHOD=([^,]+),") private val ENCRYPTION_URL_IV_REGEX = Regex("#EXT-X-KEY:METHOD=([^,]+),URI=\"([^\"]+)\"(?:,IV=(.*))?") private val QUALITY_REGEX = Regex("""#EXT-X-STREAM-INF:(?:(?:.*?(?:RESOLUTION=\d+x(\d+)).*?\s+(.*))|(?:.*?\s+(.*)))""") private val TS_EXTENSION_REGEX = - Regex("""(.*\.ts.*|.*\.jpg.*)""") //.jpg here 'case vizcloud uses .jpg instead of .ts + Regex("""#EXTINF:.*\n(.+?\n)""") // fuck it we ball, who cares about the type anyways + //Regex("""(.*\.(ts|jpg|html).*)""") //.jpg here 'case vizcloud uses .jpg instead of .ts private fun absoluteExtensionDetermination(url: String): String? { val split = url.split("/") @@ -73,7 +97,7 @@ class M3u8Helper { } }.iterator() - private fun getDecrypter( + fun getDecrypter( secretKey: ByteArray, data: ByteArray, iv: ByteArray = "".toByteArray() @@ -91,13 +115,8 @@ class M3u8Helper { return st != null && (st.value.isNotEmpty() || st.destructured.component1() != "NONE") } - data class M3u8Stream( - val streamUrl: String, - val quality: Int? = null, - val headers: Map = mapOf() - ) - private fun selectBest(qualities: List): M3u8Stream? { + private fun selectBest(qualities: List): M3u8Helper.M3u8Stream? { val result = qualities.sortedBy { if (it.quality != null && it.quality <= 1080) it.quality else 0 }.filter { @@ -113,19 +132,16 @@ class M3u8Helper { } private fun isNotCompleteUrl(url: String): Boolean { - return !url.contains("https://") && !url.contains("http://") + return !url.startsWith("https://") && !url.startsWith("http://") } - suspend fun m3u8Generation(m3u8: M3u8Stream, returnThis: Boolean? = true): List { -// return listOf(m3u8) - val list = mutableListOf() + suspend fun m3u8Generation(m3u8: M3u8Helper.M3u8Stream, returnThis: Boolean? = true): List { + val list = mutableListOf() val m3u8Parent = getParentLink(m3u8.streamUrl) val response = app.get(m3u8.streamUrl, headers = m3u8.headers, verify = false).text -// var hasAnyContent = false for (match in QUALITY_REGEX.findAll(response)) { -// hasAnyContent = true var (quality, m3u8Link, m3u8Link2) = match.destructured if (m3u8Link.isEmpty()) m3u8Link = m3u8Link2 if (absoluteExtensionDetermination(m3u8Link) == "m3u8") { @@ -136,21 +152,21 @@ class M3u8Helper { println(m3u8.streamUrl) } list += m3u8Generation( - M3u8Stream( + M3u8Helper.M3u8Stream( m3u8Link, quality.toIntOrNull(), m3u8.headers ), false ) } - list += M3u8Stream( + list += M3u8Helper.M3u8Stream( m3u8Link, quality.toIntOrNull(), m3u8.headers ) } if (returnThis != false) { - list += M3u8Stream( + list += M3u8Helper.M3u8Stream( m3u8.streamUrl, Qualities.Unknown.value, m3u8.headers @@ -160,113 +176,111 @@ class M3u8Helper { return list } + data class LazyHlsDownloadData( + private val encryptionData: ByteArray, + private val encryptionIv: ByteArray, + private val isEncrypted: Boolean, + private val allTsLinks: List, + private val relativeUrl: String, + private val headers: Map, + ) { + val size get() = allTsLinks.size - data class HlsDownloadData( - val bytes: ByteArray, - val currentIndex: Int, - val totalTs: Int, - val errored: Boolean = false - ) - - suspend fun hlsYield( - qualities: List, - startIndex: Int = 0 - ): Iterator { - if (qualities.isEmpty()) return listOf( - HlsDownloadData( - byteArrayOf(), - 1, - 1, - true - ) - ).iterator() - - var selected = selectBest(qualities) - if (selected == null) { - selected = qualities[0] + suspend fun resolveLinkSafe( + index: Int, + tries: Int = 3, + failDelay: Long = 3000 + ): ByteArray? { + for (i in 0 until tries) { + try { + return resolveLink(index) + } catch (e: IllegalArgumentException) { + return null + } catch (t: Throwable) { + delay(failDelay) + } + } + return null } + + @Throws + suspend fun resolveLink(index: Int): ByteArray { + if (index < 0 || index >= size) throw IllegalArgumentException("index must be in the bounds of the ts") + val url = allTsLinks[index] + + val tsResponse = app.get(url, headers = headers, verify = false) + val tsData = tsResponse.body.bytes() + if (tsData.isEmpty()) throw ErrorLoadingException("no data") + + return if (isEncrypted) { + getDecrypter(encryptionData, tsData, encryptionIv) + } else { + tsData + } + } + } + + @Throws + suspend fun hslLazy( + qualities: List + ): LazyHlsDownloadData { + if (qualities.isEmpty()) throw IllegalArgumentException("qualities must be non empty") + val selected = selectBest(qualities) ?: qualities.first() val headers = selected.headers - val streams = qualities.map { m3u8Generation(it, false) }.flatten() - //val sslVerification = if (headers.containsKey("ssl_verification")) headers["ssl_verification"].toBoolean() else true - + // this selects the best quality of the qualities offered, + // due to the recursive nature of m3u8, we only go 2 depth val secondSelection = selectBest(streams.ifEmpty { listOf(selected) }) - if (secondSelection != null) { - val m3u8Response = - runBlocking { - app.get( - secondSelection.streamUrl, - headers = headers, - verify = false - ).text - } + ?: throw IllegalArgumentException("qualities has no streams") - var encryptionUri: String? - var encryptionIv = byteArrayOf() - var encryptionData = byteArrayOf() + val m3u8Response = + app.get( + secondSelection.streamUrl, + headers = headers, + verify = false + ).text - val encryptionState = isEncrypted(m3u8Response) + println("m3u8Response=$m3u8Response") - if (encryptionState) { - val match = - ENCRYPTION_URL_IV_REGEX.find(m3u8Response)!!.destructured // its safe to assume that its not going to be null - encryptionUri = match.component2() + // encryption, this is because crunchy uses it + var encryptionIv = byteArrayOf() + var encryptionData = byteArrayOf() - if (isNotCompleteUrl(encryptionUri)) { - encryptionUri = "${getParentLink(secondSelection.streamUrl)}/$encryptionUri" - } + val encryptionState = isEncrypted(m3u8Response) - encryptionIv = match.component3().toByteArray() - val encryptionKeyResponse = - runBlocking { app.get(encryptionUri, headers = headers, verify = false) } - encryptionData = encryptionKeyResponse.body?.bytes() ?: byteArrayOf() + if (encryptionState) { + // its safe to assume that its not going to be null + val match = + ENCRYPTION_URL_IV_REGEX.find(m3u8Response)!!.groupValues + + var encryptionUri = match[1] + + if (isNotCompleteUrl(encryptionUri)) { + encryptionUri = "${getParentLink(secondSelection.streamUrl)}/$encryptionUri" } - val allTs = TS_EXTENSION_REGEX.findAll(m3u8Response) - val allTsList = allTs.toList() - val totalTs = allTsList.size - if (totalTs == 0) { - return listOf(HlsDownloadData(byteArrayOf(), 1, 1, true)).iterator() - } - var lastYield = 0 - - val relativeUrl = getParentLink(secondSelection.streamUrl) - var retries = 0 - val tsByteGen = sequence { - loop@ for ((index, ts) in allTs.withIndex()) { - val url = if ( - isNotCompleteUrl(ts.destructured.component1()) - ) "$relativeUrl/${ts.destructured.component1()}" else ts.destructured.component1() - val c = index + 1 + startIndex - - while (lastYield != c) { - try { - val tsResponse = - runBlocking { app.get(url, headers = headers, verify = false) } - var tsData = tsResponse.body?.bytes() ?: byteArrayOf() - - if (encryptionState) { - tsData = getDecrypter(encryptionData, tsData, encryptionIv) - yield(HlsDownloadData(tsData, c, totalTs)) - lastYield = c - break - } - yield(HlsDownloadData(tsData, c, totalTs)) - lastYield = c - } catch (e: Exception) { - logError(e) - if (retries == 3) { - yield(HlsDownloadData(byteArrayOf(), c, totalTs, true)) - break@loop - } - ++retries - Thread.sleep(2_000) - } - } - } - } - return tsByteGen.iterator() + encryptionIv = match[2].toByteArray() + val encryptionKeyResponse = app.get(encryptionUri, headers = headers, verify = false) + encryptionData = encryptionKeyResponse.body.bytes() } - return listOf(HlsDownloadData(byteArrayOf(), 1, 1, true)).iterator() + val relativeUrl = getParentLink(secondSelection.streamUrl) + val allTsList = TS_EXTENSION_REGEX.findAll(m3u8Response + "\n").map { ts -> + val value = ts.groupValues[1] + if (isNotCompleteUrl(value)) { + "$relativeUrl/${value}" + } else { + value + } + }.toList() + if (allTsList.isEmpty()) throw IllegalArgumentException("ts must be non empty") + + return LazyHlsDownloadData( + encryptionData = encryptionData, + encryptionIv = encryptionIv, + isEncrypted = encryptionState, + allTsLinks = allTsList, + relativeUrl = relativeUrl, + headers = headers + ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index c138ea75..f4eb37b7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -15,7 +15,6 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.MutableLiveData import androidx.preference.PreferenceManager import androidx.work.Data @@ -32,18 +31,15 @@ import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.internal.closeQuietly import java.io.BufferedInputStream @@ -51,11 +47,9 @@ import java.io.File import java.io.InputStream import java.io.OutputStream import java.lang.Thread.sleep -import java.net.URI import java.net.URL import java.net.URLConnection import java.util.* -import kotlin.math.roundToInt const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" @@ -92,7 +86,7 @@ object VideoDownloadManager { @DrawableRes const val pressToStopIcon = R.drawable.exo_icon_stop - private var updateCount : Int = 0 + private var updateCount: Int = 0 private val downloadDataUpdateCount = MutableLiveData() enum class DownloadType { @@ -687,7 +681,8 @@ object VideoDownloadManager { return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream) } - fun downloadThing( + @Throws + suspend fun downloadThing( context: Context, link: IDownloadableMinimum, name: String, @@ -696,9 +691,9 @@ object VideoDownloadManager { tryResume: Boolean, parentId: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit, - ): Int { + ): Int = withContext(Dispatchers.IO) { if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) { - return ERROR_UNKNOWN + return@withContext ERROR_UNKNOWN } val basePath = context.getBasePath() @@ -714,7 +709,7 @@ object VideoDownloadManager { } val stream = setupStream(context, name, relativePath, extension, tryResume) - if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode + if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode val resume = stream.resume!! val fileStream = stream.fileStream!! @@ -766,7 +761,7 @@ object VideoDownloadManager { } val bytesTotal = contentLength + resumeLength - if (extension == "mp4" && bytesTotal < 5000000) return ERROR_TOO_SMALL_CONNECTION // DATA IS LESS THAN 5MB, SOMETHING IS WRONG + if (extension == "mp4" && bytesTotal < 5000000) return@withContext ERROR_TOO_SMALL_CONNECTION // DATA IS LESS THAN 5MB, SOMETHING IS WRONG parentId?.let { setKey( @@ -845,11 +840,13 @@ object VideoDownloadManager { DownloadActionType.Pause -> { isPaused = true; updateNotification() } + DownloadActionType.Stop -> { isStopped = true; updateNotification() removeKey(KEY_RESUME_PACKAGES, event.first.toString()) saveQueue() } + DownloadActionType.Resume -> { isPaused = false; updateNotification() } @@ -917,15 +914,17 @@ object VideoDownloadManager { } // RETURN MESSAGE - return when { + return@withContext when { isFailed -> { parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) } ERROR_CONNECTION_ERROR } + isStopped -> { parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) } deleteFile() } + else -> { parentId?.let { id -> downloadProgressEvent.invoke( @@ -989,6 +988,7 @@ object VideoDownloadManager { found.delete() this.createDirectory(directoryName) } + this.isDirectory -> this.createDirectory(directoryName) else -> this.parentFile?.createDirectory(directoryName) } @@ -1107,7 +1107,8 @@ object VideoDownloadManager { return SUCCESS_STOPPED } - private fun downloadHLS( + @Throws + private suspend fun downloadHLS( context: Context, link: ExtractorLink, name: String, @@ -1115,16 +1116,8 @@ object VideoDownloadManager { parentId: Int?, startIndex: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit - ): Int { + ): Int = withContext(Dispatchers.IO) { val extension = "mp4" - fun logcatPrint(vararg items: Any?) { - items.forEach { - println("[HLS]: $it") - } - } - - val m3u8Helper = M3u8Helper() - logcatPrint("initialised the HLS downloader.") val m3u8 = M3u8Helper.M3u8Stream( link.url, link.quality, mapOf("referer" to link.referer) @@ -1139,54 +1132,40 @@ object VideoDownloadManager { ) else folder val stream = setupStream(context, name, relativePath, extension, realIndex > 0) - if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode + if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode - if (!stream.resume!!) realIndex = 0 - val fileLengthAdd = stream.fileLength!! - val tsIterator = runBlocking { - m3u8Helper.hlsYield(listOf(m3u8), realIndex) - } + if (stream.resume != true) realIndex = 0 + val fileLengthAdd = stream.fileLength ?: 0 + val items = M3u8Helper2.hslLazy(listOf(m3u8)) val displayName = getDisplayName(name, extension) val fileStream = stream.fileStream!! - val firstTs = tsIterator.next() - var isDone = false var isFailed = false var isPaused = false - var bytesDownloaded = firstTs.bytes.size.toLong() + fileLengthAdd - var tsProgress = 1L + realIndex - val totalTs = firstTs.totalTs.toLong() + var bytesDownloaded = fileLengthAdd + var tsProgress: Long = realIndex.toLong() + 1 // we don't want div by zero + val totalTs: Long = items.size.toLong() fun deleteFile(): Int { return delete(context, name, relativePath, extension, parentId, basePath.first) } - /* - Most of the auto generated m3u8 out there have TS of the same size. - And only the last TS might have a different size. - - But oh well, in cases of handmade m3u8 streams this will go all over the place ¯\_(ツ)_/¯ - So ya, this calculates an estimate of how many bytes the file is going to be. - - > (bytesDownloaded/tsProgress)*totalTs - */ fun updateInfo() { - parentId?.let { - setKey( - KEY_DOWNLOAD_INFO, - it.toString(), - DownloadedFileInfo( - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - relativePath ?: "", - displayName, - tsProgress.toString(), - basePath = basePath.second - ) + setKey( + KEY_DOWNLOAD_INFO, + (parentId ?: return).toString(), + DownloadedFileInfo( + // approx bytes + totalBytes = (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), + relativePath = relativePath ?: "", + displayName = displayName, + extraInfo = tsProgress.toString(), + basePath = basePath.second ) - } + ) } updateInfo() @@ -1210,9 +1189,7 @@ object VideoDownloadManager { (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), ) ) - } catch (e: Exception) { - // IDK MIGHT ERROR - } + } catch (_: Throwable) {} } createNotificationCallback.invoke( @@ -1226,24 +1203,6 @@ object VideoDownloadManager { ) } - fun stopIfError(ts: M3u8Helper.HlsDownloadData): Int? { - if (ts.errored || ts.bytes.isEmpty()) { - val error: Int = if (!ts.errored) { - logcatPrint("Error: No stream was found.") - ERROR_UNKNOWN - } else { - logcatPrint("Error: Failed to fetch data.") - ERROR_CONNECTION_ERROR - } - isFailed = true - fileStream.close() - deleteFile() - updateNotification() - return error - } - return null - } - val notificationCoroutine = main { while (true) { if (!isDone) { @@ -1261,11 +1220,11 @@ object VideoDownloadManager { DownloadActionType.Stop -> { isFailed = true } + DownloadActionType.Pause -> { - isPaused = - true // Pausing is not supported since well...I need to know the index of the ts it was paused at - // it may be possible to store it in a variable, but when the app restarts it will be lost + isPaused = true } + DownloadActionType.Resume -> { isPaused = false } @@ -1278,32 +1237,22 @@ object VideoDownloadManager { try { if (parentId != null) downloadEvent -= downloadEventListener - } catch (e: Exception) { - logError(e) + } catch (t: Throwable) { + logError(t) } try { parentId?.let { downloadStatus.remove(it) } - } catch (e: Exception) { - logError(e) - // IDK MIGHT ERROR + } catch (t: Throwable) { + logError(t) } notificationCoroutine.cancel() } - stopIfError(firstTs).let { - if (it != null) { - closeAll() - return it - } - } - if (parentId != null) downloadEvent += downloadEventListener - fileStream.write(firstTs.bytes) - fun onFailed() { fileStream.close() deleteFile() @@ -1311,31 +1260,29 @@ object VideoDownloadManager { closeAll() } - for (ts in tsIterator) { + for (idx in realIndex until items.size) { while (isPaused) { if (isFailed) { onFailed() - return SUCCESS_STOPPED + return@withContext SUCCESS_STOPPED } - sleep(100) + delay(100) } if (isFailed) { onFailed() - return SUCCESS_STOPPED + return@withContext SUCCESS_STOPPED } - stopIfError(ts).let { - if (it != null) { - closeAll() - return it - } + val bytes = items.resolveLinkSafe(idx) ?: run { + isFailed = true + onFailed() + return@withContext ERROR_CONNECTION_ERROR } - fileStream.write(ts.bytes) - tsProgress = ts.currentIndex.toLong() - bytesDownloaded += ts.bytes.size.toLong() - logcatPrint("Download progress ${((tsProgress.toFloat() / totalTs.toFloat()) * 100).roundToInt()}%") + fileStream.write(bytes) + tsProgress = idx.toLong() + 1 + bytesDownloaded += bytes.size.toLong() updateInfo() } isDone = true @@ -1344,7 +1291,7 @@ object VideoDownloadManager { closeAll() updateInfo() - return SUCCESS_DOWNLOAD_DONE + return@withContext SUCCESS_DOWNLOAD_DONE } fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { @@ -1379,7 +1326,7 @@ object VideoDownloadManager { ) } - private fun downloadSingleEpisode( + private suspend fun downloadSingleEpisode( context: Context, source: String?, folder: String?, @@ -1405,25 +1352,29 @@ object VideoDownloadManager { null )?.extraInfo?.toIntOrNull() } else null - return downloadHLS(context, link, name, folder, ep.id, startIndex) { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback, - meta.hlsProgress, - meta.hlsTotal - ) + return suspendSafeApiCall { + downloadHLS(context, link, name, folder, ep.id, startIndex) { meta -> + main { + createNotification( + context, + source, + link.name, + ep, + meta.type, + meta.bytesDownloaded, + meta.bytesTotal, + notificationCallback, + meta.hlsProgress, + meta.hlsTotal + ) + } } - }.also { extractorJob.cancel() } + }.also { + extractorJob.cancel() + } ?: ERROR_UNKNOWN } - return normalSafeApiCall { + return suspendSafeApiCall { downloadThing(context, link, name, folder, "mp4", tryResume, ep.id) { meta -> main { createNotification( @@ -1468,17 +1419,15 @@ object VideoDownloadManager { DownloadResumePackage(item, index) ) val connectionResult = withContext(Dispatchers.IO) { - normalSafeApiCall { - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ).also { println("Single episode finished with return code: $it") } - } + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ).also { println("Single episode finished with return code: $it") } } if (connectionResult != null && connectionResult > 0) { // SUCCESS removeKey(KEY_RESUME_PACKAGES, id.toString()) From 61d63b17d819d7899e04314293d8f01b651ef22a Mon Sep 17 00:00:00 2001 From: IndusAryan <125901294+IndusAryan@users.noreply.github.com> Date: Fri, 18 Aug 2023 02:41:59 +0530 Subject: [PATCH 07/39] chore: acra improvements and media3 bump (#562) * Acra Bump * Media3 bump --- app/build.gradle.kts | 20 +++++++++---------- .../lagradost/cloudstream3/AcraApplication.kt | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b228fea0..3b215dbc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,22 +181,22 @@ dependencies { // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09") // Media 3 - implementation("androidx.media3:media3-common:1.1.0") - implementation("androidx.media3:media3-exoplayer:1.1.0") - implementation("androidx.media3:media3-datasource-okhttp:1.1.0") - implementation("androidx.media3:media3-ui:1.1.0") - implementation("androidx.media3:media3-session:1.1.0") - implementation("androidx.media3:media3-cast:1.1.0") - implementation("androidx.media3:media3-exoplayer-hls:1.1.0") - implementation("androidx.media3:media3-exoplayer-dash:1.1.0") + implementation("androidx.media3:media3-common:1.1.1") + implementation("androidx.media3:media3-exoplayer:1.1.1") + implementation("androidx.media3:media3-datasource-okhttp:1.1.1") + implementation("androidx.media3:media3-ui:1.1.1") + implementation("androidx.media3:media3-session:1.1.1") + implementation("androidx.media3:media3-cast:1.1.1") + implementation("androidx.media3:media3-exoplayer-hls:1.1.1") + implementation("androidx.media3:media3-exoplayer-dash:1.1.1") // Custom ffmpeg extension for audio codecs implementation("com.github.recloudstream:media-ffmpeg:1.1.0") //implementation("com.google.android.exoplayer:extension-leanback:2.14.0") // Bug reports - implementation("ch.acra:acra-core:5.8.4") - implementation("ch.acra:acra-toast:5.8.4") + implementation("ch.acra:acra-core:5.11.0") + implementation("ch.acra:acra-toast:5.11.0") compileOnly("com.google.auto.service:auto-service-annotations:1.0") //either for java sources: diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index 069287b0..61d467c4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -121,10 +121,10 @@ class AcraApplication : Application() { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON - reportContent = arrayOf( + reportContent = listOf( ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, - ReportField.STACK_TRACE + ReportField.STACK_TRACE, ) // removed this due to bug when starting the app, moved it to when it actually crashes @@ -213,4 +213,4 @@ class AcraApplication : Application() { } } -} \ No newline at end of file +} From 8f6e8a8e99c349489f05294e2d46a9ce58afc1ae Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Fri, 18 Aug 2023 01:46:29 +0200 Subject: [PATCH 08/39] fixed #547 fuck inheritance --- app/build.gradle.kts | 2 +- .../ui/result/ResultFragmentPhone.kt | 53 ++++++++++++------- .../ui/result/ResultTrailerPlayer.kt | 3 -- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b215dbc..f72ec0b0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -231,7 +231,7 @@ dependencies { // API because cba maintaining it myself implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0") - implementation("com.github.discord:OverlappingPanels:0.1.3") + implementation("com.github.discord:OverlappingPanels:0.1.5") // debugImplementation because LeakCanary should only run in debug builds. //debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 633ee762..ae0b7419 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -23,7 +23,6 @@ import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.RecyclerView import com.discord.panels.OverlappingPanelsLayout import com.discord.panels.PanelsChildGestureRegionObserver import com.google.android.gms.cast.framework.CastButtonFactory @@ -62,8 +61,6 @@ import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.utils.AppUtils.getNameFull import com.lagradost.cloudstream3.utils.AppUtils.html import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable -import com.lagradost.cloudstream3.utils.AppUtils.isLtr -import com.lagradost.cloudstream3.utils.AppUtils.isRtl import com.lagradost.cloudstream3.utils.AppUtils.loadCache import com.lagradost.cloudstream3.utils.AppUtils.openBrowser import com.lagradost.cloudstream3.utils.ExtractorLink @@ -81,8 +78,13 @@ import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.VideoDownloadHelper -open class ResultFragmentPhone : FullScreenPlayer(), - PanelsChildGestureRegionObserver.GestureRegionsListener { +open class ResultFragmentPhone : FullScreenPlayer() { + private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { + override fun onGestureRegionsUpdate(gestureRegions: List) { + binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions) + } + } + protected lateinit var viewModel: ResultViewModel2 protected lateinit var syncModel: SyncViewModel @@ -210,15 +212,20 @@ open class ResultFragmentPhone : FullScreenPlayer(), loadTrailer() } + override fun onDestroy() { + super.onDestroy() + PanelsChildGestureRegionObserver.Provider.get().apply { + resultBinding?.resultCastItems?.let { + unregister(it) + } + removeGestureRegionsUpdateListener(gestureRegionsListener) + } + } + override fun onDestroyView() { //somehow this still leaks and I dont know why???? // todo look at https://github.com/discord/OverlappingPanels/blob/70b4a7cf43c6771873b1e091029d332896d41a1a/sample_app/src/main/java/com/discord/sampleapp/MainActivity.kt - PanelsChildGestureRegionObserver.Provider.get().let { obs -> - resultBinding?.resultCastItems?.let { - obs.unregister(it) - } - obs.removeGestureRegionsUpdateListener(this) - } + updateUIEvent -= ::updateUI binding = null resultBinding = null @@ -287,6 +294,8 @@ open class ResultFragmentPhone : FullScreenPlayer(), it.colorFromAttribute(R.attr.primaryBlackBackground) } super.onResume() + PanelsChildGestureRegionObserver.Provider.get() + .addGestureRegionsUpdateListener(gestureRegionsListener) } override fun onStop() { @@ -323,7 +332,16 @@ open class ResultFragmentPhone : FullScreenPlayer(), setUrl(storedData.url) syncModel.addFromUrl(storedData.url) val api = APIHolder.getApiFromNameNull(storedData.apiName) - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) + + PanelsChildGestureRegionObserver.Provider.get().apply { + resultBinding?.resultCastItems?.let { + register(it) + } + addGestureRegionsUpdateListener(gestureRegionsListener) + } + + + // ===== ===== ===== resultBinding?.apply { @@ -374,9 +392,8 @@ open class ResultFragmentPhone : FullScreenPlayer(), DownloadButtonSetup.handleDownloadClick(downloadClickEvent) } ) - resultCastItems.let { - PanelsChildGestureRegionObserver.Provider.get().register(it) - } + + resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY if (dy > 0) { //check for scroll down @@ -1055,11 +1072,7 @@ open class ResultFragmentPhone : FullScreenPlayer(), override fun onPause() { super.onPause() - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) - } - - override fun onGestureRegionsUpdate(gestureRegions: List) { - binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions) + PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(gestureRegionsListener) } private fun setRecommendations(rec: List?, validApiName: String?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 1f663e31..eb8cb9b3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -118,9 +118,6 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { override fun onTracksInfoChanged() {} override fun exitedPipMode() {} - - override fun onGestureRegionsUpdate(gestureRegions: List) {} - private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen From e95dc1db2a94a4f3f5583bc626e331129be77a38 Mon Sep 17 00:00:00 2001 From: IndusAryan <125901294+IndusAryan@users.noreply.github.com> Date: Fri, 18 Aug 2023 21:16:03 +0530 Subject: [PATCH 09/39] fix: cast items recycler (finally) (#564) * turn cast items visible(tools) * prevent cast gesture listener from permanent RIP in one lifecycle --- .../ui/result/ResultFragmentPhone.kt | 20 +++++++++---------- app/src/main/res/layout/fragment_result.xml | 4 ++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index ae0b7419..a932a57c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -212,19 +212,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { loadTrailer() } - override fun onDestroy() { - super.onDestroy() - PanelsChildGestureRegionObserver.Provider.get().apply { - resultBinding?.resultCastItems?.let { - unregister(it) - } - removeGestureRegionsUpdateListener(gestureRegionsListener) - } - } - override fun onDestroyView() { + //somehow this still leaks and I dont know why???? // todo look at https://github.com/discord/OverlappingPanels/blob/70b4a7cf43c6771873b1e091029d332896d41a1a/sample_app/src/main/java/com/discord/sampleapp/MainActivity.kt + PanelsChildGestureRegionObserver.Provider.get().let { obs -> + resultBinding?.resultCastItems?.let { + obs.unregister(it) + } + + obs.removeGestureRegionsUpdateListener(gestureRegionsListener) + } updateUIEvent -= ::updateUI binding = null @@ -1127,4 +1125,4 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml index ee3477b0..87de7186 100644 --- a/app/src/main/res/layout/fragment_result.xml +++ b/app/src/main/res/layout/fragment_result.xml @@ -476,7 +476,7 @@ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:itemCount="2" tools:listitem="@layout/cast_item" - tools:visibility="gone" /> + tools:visibility="visible" /> --> - \ No newline at end of file + From 56cb3d718188bf95e16cc062280bc5a16e42ea24 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 19 Aug 2023 00:48:00 +0200 Subject: [PATCH 10/39] refactored download system for better preference + bugfixes --- .../cloudstream3/DownloaderTestImpl.kt | 2 +- .../com/lagradost/cloudstream3/MainAPI.kt | 2 +- .../ui/download/DownloadChildAdapter.kt | 2 +- .../ui/download/EasyDownloadButton.kt | 264 ----- .../ui/download/button/BaseFetchButton.kt | 61 +- .../ui/download/button/DownloadButton.kt | 17 +- .../ui/download/button/PieFetchButton.kt | 53 +- .../cloudstream3/utils/M3u8Helper.kt | 23 +- .../cloudstream3/utils/VideoDownloadHelper.kt | 6 +- .../utils/VideoDownloadManager.kt | 1016 ++++++++--------- .../main/res/drawable/baseline_stop_24.xml | 10 + 11 files changed, 604 insertions(+), 852 deletions(-) delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt create mode 100644 app/src/main/res/drawable/baseline_stop_24.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt index 379a91e4..0a2db2bd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt @@ -50,7 +50,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do companion object { private const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" private var instance: DownloaderTestImpl? = null /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt index 7790f047..80332445 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt @@ -29,7 +29,7 @@ import java.util.* import kotlin.math.absoluteValue const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" //val baseHeader = mapOf("User-Agent" to USER_AGENT) val mapper = JsonMapper.builder().addModule(KotlinModule()) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt index b4774cf8..1d7b5a83 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt @@ -23,7 +23,7 @@ data class VisualDownloadChildCached( val data: VideoDownloadHelper.DownloadEpisodeCached, ) -data class DownloadClickEvent(val action: Int, val data: EasyDownloadButton.IMinimumData) +data class DownloadClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadEpisodeCached) class DownloadChildAdapter( var cardList: List, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt deleted file mode 100644 index 77878432..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -import android.animation.ObjectAnimator -import android.text.format.Formatter.formatShortFileSize -import android.view.View -import android.view.animation.DecelerateInterpolator -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.widget.ContentLoadingProgressBar -import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.Coroutines -import com.lagradost.cloudstream3.utils.IDisposable -import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.VideoDownloadManager - -class EasyDownloadButton : IDisposable { - interface IMinimumData { - val id: Int - } - - private var _clickCallback: ((DownloadClickEvent) -> Unit)? = null - private var _imageChangeCallback: ((Pair) -> Unit)? = null - - override fun dispose() { - try { - _clickCallback = null - _imageChangeCallback = null - downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent -= it } - downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent -= it } - } catch (e: Exception) { - e.printStackTrace() - } - } - - private var downloadProgressEventListener: ((Triple) -> Unit)? = null - private var downloadStatusEventListener: ((Pair) -> Unit)? = - null - - fun setUpMaterialButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadButton: MaterialButton, - textView: TextView?, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textView, - data, - downloadButton, - { - downloadButton.setIconResource(it.first) - downloadButton.text = it.second - }, - clickCallback - ) - } - - fun setUpMoreButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadImage: ImageView, - textView: TextView?, - textViewProgress: TextView?, - clickableView: View, - isTextPercentage: Boolean, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textViewProgress, - data, - clickableView, - { (image, text) -> - downloadImage.isVisible = textViewProgress?.isGone ?: true - downloadImage.setImageResource(image) - textView?.text = text - }, - clickCallback, isTextPercentage - ) - } - - fun setUpButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadImage: ImageView, - textView: TextView?, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textView, - data, - downloadImage, - { - downloadImage.setImageResource(it.first) - }, - clickCallback - ) - } - - private fun setUpDownloadButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - textView: TextView?, - data: IMinimumData, - downloadView: View, - downloadImageChangeCallback: (Pair) -> Unit, - clickCallback: (DownloadClickEvent) -> Unit, - isTextPercentage: Boolean = false - ) { - _clickCallback = clickCallback - _imageChangeCallback = downloadImageChangeCallback - var lastState: VideoDownloadManager.DownloadType? = null - var currentBytes = setupCurrentBytes ?: 0 - var totalBytes = setupTotalBytes ?: 0 - var needImageUpdate = true - - fun changeDownloadImage(state: VideoDownloadManager.DownloadType) { - lastState = state - if (currentBytes <= 0) needImageUpdate = true - val img = if (currentBytes > 0) { - when (state) { - VideoDownloadManager.DownloadType.IsPaused -> Pair( - R.drawable.ic_baseline_play_arrow_24, - R.string.download_paused - ) - VideoDownloadManager.DownloadType.IsDownloading -> Pair( - R.drawable.netflix_pause, - R.string.downloading - ) - else -> Pair(R.drawable.ic_baseline_delete_outline_24, R.string.downloaded) - } - } else { - Pair(R.drawable.netflix_download, R.string.download) - } - _imageChangeCallback?.invoke( - Pair( - img.first, - downloadView.context.getString(img.second) - ) - ) - } - - fun fixDownloadedBytes(setCurrentBytes: Long, setTotalBytes: Long, animate: Boolean) { - currentBytes = setCurrentBytes - totalBytes = setTotalBytes - - if (currentBytes == 0L) { - changeDownloadImage(VideoDownloadManager.DownloadType.IsStopped) - textView?.visibility = View.GONE - progressBar.visibility = View.GONE - } else { - if (lastState == VideoDownloadManager.DownloadType.IsStopped) { - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - } - textView?.visibility = View.VISIBLE - progressBar.visibility = View.VISIBLE - val currentMbString = formatShortFileSize(textView?.context, setCurrentBytes) - val totalMbString = formatShortFileSize(textView?.context, setTotalBytes) - - textView?.text = - if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else - textView?.context?.getString(R.string.download_size_format) - ?.format(currentMbString, totalMbString) - - progressBar.let { bar -> - bar.max = (setTotalBytes / 1000).toInt() - - if (animate) { - val animation: ObjectAnimator = ObjectAnimator.ofInt( - bar, - "progress", - bar.progress, - (setCurrentBytes / 1000).toInt() - ) - animation.duration = 500 - animation.setAutoCancel(true) - animation.interpolator = DecelerateInterpolator() - animation.start() - } else { - bar.progress = (setCurrentBytes / 1000).toInt() - } - } - } - } - - fixDownloadedBytes(currentBytes, totalBytes, false) - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - - downloadProgressEventListener = { downloadData: Triple -> - if (data.id == downloadData.first) { - if (downloadData.second != currentBytes || downloadData.third != totalBytes) { // TO PREVENT WASTING UI TIME - Coroutines.runOnMainThread { - fixDownloadedBytes(downloadData.second, downloadData.third, true) - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - } - } - } - } - - downloadStatusEventListener = - { downloadData: Pair -> - if (data.id == downloadData.first) { - if (lastState != downloadData.second || needImageUpdate) { // TO PREVENT WASTING UI TIME - Coroutines.runOnMainThread { - changeDownloadImage(downloadData.second) - } - } - } - } - - downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent += it } - downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent += it } - - downloadView.setOnClickListener { - if (currentBytes <= 0 || totalBytes <= 0) { - _clickCallback?.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) - } else { - val list = arrayListOf( - Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), - Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file), - ) - - // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone && - if ((currentBytes * 100 / totalBytes) < 98) { - list.add( - if (lastState == VideoDownloadManager.DownloadType.IsDownloading) - Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download) - else - Pair(DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download) - ) - } - - it.popupMenuNoIcons( - list - ) { - _clickCallback?.invoke(DownloadClickEvent(itemId, data)) - } - } - } - - downloadView.setOnLongClickListener { - clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data)) - return@setOnLongClickListener true - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt index 05f630a0..b43f1aac 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -22,7 +22,7 @@ data class DownloadMetadata( val progressPercentage: Long get() = if (downloadedLength < 1024) 0 else maxOf( 0, - minOf(100, (downloadedLength * 100L) / totalLength) + minOf(100, (downloadedLength * 100L) / (totalLength + 1)) ) } @@ -101,38 +101,41 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : open fun setProgress(downloadedBytes: Long, totalBytes: Long) { isZeroBytes = downloadedBytes == 0L - val steps = 10000L - progressBar.max = steps.toInt() - // div by zero error and 1 byte off is ok impo - val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt() + progressBar.post { + val steps = 10000L + progressBar.max = steps.toInt() + // div by zero error and 1 byte off is ok impo - val animation = ProgressBarAnimation( - progressBar, - progressBar.progress.toFloat(), - progress.toFloat() - ).apply { - fillAfter = true - duration = - if (progress > progressBar.progress) // we don't want to animate backward changes in progress - 100 - else - 0L - } + val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt() - if (isZeroBytes) { - progressText?.isVisible = false - } else { - progressText?.apply { - val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes) - val totalMbString = Formatter.formatShortFileSize(context, totalBytes) - text = - //if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else - context?.getString(R.string.download_size_format) - ?.format(currentMbString, totalMbString) + val animation = ProgressBarAnimation( + progressBar, + progressBar.progress.toFloat(), + progress.toFloat() + ).apply { + fillAfter = true + duration = + if (progress > progressBar.progress) // we don't want to animate backward changes in progress + 100 + else + 0L } - } - progressBar.startAnimation(animation) + if (isZeroBytes) { + progressText?.isVisible = false + } else { + progressText?.apply { + val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes) + val totalMbString = Formatter.formatShortFileSize(context, totalBytes) + text = + //if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else + context?.getString(R.string.download_size_format) + ?.format(currentMbString, totalMbString) + } + } + + progressBar.startAnimation(animation) + } } fun downloadStatusEvent(data: Pair) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt index bb2ba7b1..d97a4b88 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt @@ -21,14 +21,17 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setStatus(status: DownloadStatusTell?) { - super.setStatus(status) - val txt = when (status) { - DownloadStatusTell.IsPaused -> R.string.download_paused - DownloadStatusTell.IsDownloading -> R.string.downloading - DownloadStatusTell.IsDone -> R.string.downloaded - else -> R.string.download + mainText?.post { + val txt = when (status) { + DownloadStatusTell.IsPaused -> R.string.download_paused + DownloadStatusTell.IsDownloading -> R.string.downloading + DownloadStatusTell.IsDone -> R.string.downloaded + else -> R.string.download + } + mainText?.setText(txt) } - mainText?.setText(txt) + super.setStatus(status) + } override fun setDefaultClickListener( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt index 0b7a7fea..d20fcf93 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -174,7 +174,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : currentMetaData.apply { // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone && - if ((downloadedLength * 100 / totalLength) < 98) { + if (progressPercentage < 98) { list.add( if (status == VideoDownloadManager.DownloadType.IsDownloading) Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download) @@ -248,33 +248,34 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : //progressBar.isVisible = // status != null && status != DownloadStatusTell.Complete && status != DownloadStatusTell.Error //progressBarBackground.isVisible = status != null && status != DownloadStatusTell.Complete - val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading + progressBarBackground.post { + val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading + if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { + val animation = AnimationUtils.loadAnimation(context, waitingAnimation) + progressBarBackground.startAnimation(animation) + } else { + progressBarBackground.clearAnimation() + } - if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { - val animation = AnimationUtils.loadAnimation(context, waitingAnimation) - progressBarBackground.startAnimation(animation) - } else { - progressBarBackground.clearAnimation() + val progressDrawable = + if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline + + progressBarBackground.background = + ContextCompat.getDrawable(context, progressDrawable) + + val drawable = getDrawableFromStatus(status) + statusView.setImageDrawable(drawable) + val isDrawable = drawable != null + + statusView.isVisible = isDrawable + val hide = hideWhenIcon && isDrawable + if (hide) { + progressBar.clearAnimation() + progressBarBackground.clearAnimation() + } + progressBarBackground.isGone = hide + progressBar.isGone = hide } - - val progressDrawable = - if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline - - progressBarBackground.background = - ContextCompat.getDrawable(context, progressDrawable) - - val drawable = getDrawableFromStatus(status) - statusView.setImageDrawable(drawable) - val isDrawable = drawable != null - - statusView.isVisible = isDrawable - val hide = hideWhenIcon && isDrawable - if (hide) { - progressBar.clearAnimation() - progressBarBackground.clearAnimation() - } - progressBarBackground.isGone = hide - progressBar.isGone = hide } override fun resetView() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt index 6770e303..1fb3a72d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt @@ -186,6 +186,27 @@ object M3u8Helper2 { ) { val size get() = allTsLinks.size + suspend fun resolveLinkWhileSafe( + index: Int, + tries: Int = 3, + failDelay: Long = 3000, + condition : (() -> Boolean) + ): ByteArray? { + for (i in 0 until tries) { + if(!condition()) return null + + try { + val out = resolveLink(index) + return if(condition()) out else null + } catch (e: IllegalArgumentException) { + return null + } catch (t: Throwable) { + delay(failDelay) + } + } + return null + } + suspend fun resolveLinkSafe( index: Int, tries: Int = 3, @@ -240,8 +261,6 @@ object M3u8Helper2 { verify = false ).text - println("m3u8Response=$m3u8Response") - // encryption, this is because crunchy uses it var encryptionIv = byteArrayOf() var encryptionData = byteArrayOf() diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt index a76cc115..d1614bc1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -2,20 +2,18 @@ package com.lagradost.cloudstream3.utils import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.ui.download.EasyDownloadButton - object VideoDownloadHelper { data class DownloadEpisodeCached( @JsonProperty("name") val name: String?, @JsonProperty("poster") val poster: String?, @JsonProperty("episode") val episode: Int, @JsonProperty("season") val season: Int?, - @JsonProperty("id") override val id: Int, + @JsonProperty("id") val id: Int, @JsonProperty("parentId") val parentId: Int, @JsonProperty("rating") val rating: Int?, @JsonProperty("description") val description: String?, @JsonProperty("cacheTime") val cacheTime: Long, - ) : EasyDownloadButton.IMinimumData + ) data class DownloadHeaderCached( @JsonProperty("apiName") val apiName: String, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index f4eb37b7..0334103f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -15,7 +15,6 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri -import androidx.lifecycle.MutableLiveData import androidx.preference.PreferenceManager import androidx.work.Data import androidx.work.ExistingWorkPolicy @@ -30,6 +29,8 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.services.VideoDownloadService @@ -42,13 +43,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import okhttp3.internal.closeQuietly -import java.io.BufferedInputStream +import java.io.Closeable import java.io.File +import java.io.IOException import java.io.InputStream import java.io.OutputStream -import java.lang.Thread.sleep import java.net.URL -import java.net.URLConnection import java.util.* const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" @@ -60,34 +60,31 @@ object VideoDownloadManager { private var currentDownloads = mutableListOf() private const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" - @DrawableRes - const val imgDone = R.drawable.rddone + @get:DrawableRes + val imgDone get() = R.drawable.rddone - @DrawableRes - const val imgDownloading = R.drawable.rdload + @get:DrawableRes + val imgDownloading get() = R.drawable.rdload - @DrawableRes - const val imgPaused = R.drawable.rdpause + @get:DrawableRes + val imgPaused get() = R.drawable.rdpause - @DrawableRes - const val imgStopped = R.drawable.rderror + @get:DrawableRes + val imgStopped get() = R.drawable.rderror - @DrawableRes - const val imgError = R.drawable.rderror + @get:DrawableRes + val imgError get() = R.drawable.rderror - @DrawableRes - const val pressToPauseIcon = R.drawable.ic_baseline_pause_24 + @get:DrawableRes + val pressToPauseIcon get() = R.drawable.ic_baseline_pause_24 - @DrawableRes - const val pressToResumeIcon = R.drawable.ic_baseline_play_arrow_24 + @get:DrawableRes + val pressToResumeIcon get() = R.drawable.ic_baseline_play_arrow_24 - @DrawableRes - const val pressToStopIcon = R.drawable.exo_icon_stop - - private var updateCount: Int = 0 - private val downloadDataUpdateCount = MutableLiveData() + @get:DrawableRes + val pressToStopIcon get() = R.drawable.baseline_stop_24 enum class DownloadType { IsPaused, @@ -251,9 +248,8 @@ object VideoDownloadManager { total: Long, notificationCallback: (Int, Notification) -> Unit, hlsProgress: Long? = null, - hlsTotal: Long? = null, - - ): Notification? { + hlsTotal: Long? = null + ): Notification? { try { if (total <= 0) return null// crash, invalid data @@ -336,14 +332,28 @@ object VideoDownloadManager { } val bigText = - if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { - (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix" - } else if (state == DownloadType.IsFailed) { - downloadFormat.format(context.getString(R.string.download_failed), rowTwo) - } else if (state == DownloadType.IsDone) { - downloadFormat.format(context.getString(R.string.download_done), rowTwo) - } else { - downloadFormat.format(context.getString(R.string.download_canceled), rowTwo) + when (state) { + DownloadType.IsDownloading, DownloadType.IsPaused -> { + (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix" + } + + DownloadType.IsFailed -> { + downloadFormat.format( + context.getString(R.string.download_failed), + rowTwo + ) + } + + DownloadType.IsDone -> { + downloadFormat.format(context.getString(R.string.download_done), rowTwo) + } + + else -> { + downloadFormat.format( + context.getString(R.string.download_canceled), + rowTwo + ) + } } val bodyStyle = NotificationCompat.BigTextStyle() @@ -351,14 +361,28 @@ object VideoDownloadManager { builder.setStyle(bodyStyle) } else { val txt = - if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { - rowTwo - } else if (state == DownloadType.IsFailed) { - downloadFormat.format(context.getString(R.string.download_failed), rowTwo) - } else if (state == DownloadType.IsDone) { - downloadFormat.format(context.getString(R.string.download_done), rowTwo) - } else { - downloadFormat.format(context.getString(R.string.download_canceled), rowTwo) + when (state) { + DownloadType.IsDownloading, DownloadType.IsPaused -> { + rowTwo + } + + DownloadType.IsFailed -> { + downloadFormat.format( + context.getString(R.string.download_failed), + rowTwo + ) + } + + DownloadType.IsDone -> { + downloadFormat.format(context.getString(R.string.download_done), rowTwo) + } + + else -> { + downloadFormat.format( + context.getString(R.string.download_canceled), + rowTwo + ) + } } builder.setContentText(txt) @@ -681,6 +705,171 @@ object VideoDownloadManager { return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream) } + /** This class handles the notifications, as well as the relevant key */ + data class DownloadMetaData( + private val id: Int?, + var bytesDownloaded: Long = 0, + var totalBytes: Long? = null, + + // notification metadata + private var lastUpdatedMs: Long = 0, + private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, + + private var internalType: DownloadType = DownloadType.IsPending, + + // how many segments that we have downloaded + var hlsProgress: Int = 0, + // how many segments that exist + var hlsTotal: Int? = null, + // this is how many segments that has been written to the file + // will always be <= hlsProgress as we may keep some in a buffer + var hlsWrittenProgress: Long = 0, + + // this is used for copy with metadata on how much we have downloaded for setKey + private var downloadFileInfoTemplate: DownloadedFileInfo? = null + ) : Closeable { + val approxTotalBytes: Long + get() = totalBytes ?: hlsTotal?.let { total -> + (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() + } ?: 0L + + private val isHLS get() = hlsTotal != null + + private val downloadEventListener = { event: Pair -> + if (event.first == id) { + when (event.second) { + DownloadActionType.Pause -> { + type = DownloadType.IsPaused + } + + DownloadActionType.Stop -> { + type = DownloadType.IsStopped + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + saveQueue() + } + + DownloadActionType.Resume -> { + type = DownloadType.IsDownloading + } + } + } + } + + private fun updateFileInfo() { + if (id == null) return + downloadFileInfoTemplate?.let { template -> + setKey( + KEY_DOWNLOAD_INFO, + id.toString(), + template.copy( + totalBytes = approxTotalBytes, + extraInfo = if (isHLS) hlsWrittenProgress.toString() else null + ) + ) + } + } + + fun setDownloadFileInfoTemplate(template: DownloadedFileInfo) { + downloadFileInfoTemplate = template + updateFileInfo() + } + + init { + if (id != null) { + downloadEvent += downloadEventListener + } + } + + override fun close() { + // as we may need to resume hls downloads, we save the current written index + if (isHLS) { + updateFileInfo() + } + if (id != null) { + downloadEvent -= downloadEventListener + downloadStatus -= id + } + } + + var type + get() = internalType + set(value) { + internalType = value + notify() + } + + companion object { + const val UPDATE_RATE_MS: Long = 1000L + } + + @JvmName("DownloadMetaDataNotify") + private fun notify() { + lastUpdatedMs = System.currentTimeMillis() + try { + val bytes = approxTotalBytes + + // notification creation + if (isHLS) { + createNotificationCallback( + CreateNotificationMetadata( + internalType, + bytesDownloaded, + bytes, + hlsTotal = hlsTotal?.toLong(), + hlsProgress = hlsProgress.toLong() + ) + ) + } else { + createNotificationCallback( + CreateNotificationMetadata( + internalType, + bytesDownloaded, + bytes, + ) + ) + } + + // as hls has an approx file size we want to update this metadata + if (isHLS) { + updateFileInfo() + } + + // push all events, this *should* not crash, TODO MUTEX? + if (id != null) { + downloadStatus[id] = type + downloadProgressEvent(Triple(id, bytesDownloaded, bytes)) + downloadStatusEvent(id to type) + } + } catch (t: Throwable) { + logError(t) + } + } + + private fun checkNotification() { + if (lastUpdatedMs + UPDATE_RATE_MS > System.currentTimeMillis()) return + notify() + } + + + /** adds the length and pushes a notification if necessary */ + fun addBytes(length: Long) { + bytesDownloaded += length + // we don't want to update the notification after it is paused, + // download progress may not stop directly when we "pause" it + if (type == DownloadType.IsDownloading) checkNotification() + } + + /** adds the length + hsl progress and pushes a notification if necessary */ + fun addSegment(length: Long) { + hlsProgress += 1 + addBytes(length) + } + + fun setWrittenSegment(segmentIndex: Int) { + hlsWrittenProgress = segmentIndex.toLong() + 1L + } + } + @Throws suspend fun downloadThing( context: Context, @@ -692,253 +881,225 @@ object VideoDownloadManager { parentId: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit, ): Int = withContext(Dispatchers.IO) { + // we cant download torrents with this implementation, aria2c might be used in the future if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) { return@withContext ERROR_UNKNOWN } - val basePath = context.getBasePath() - - val displayName = getDisplayName(name, extension) - val relativePath = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.first.isDownloadDir()) getRelativePath( - folder - ) else folder - - fun deleteFile(): Int { - return delete(context, name, relativePath, extension, parentId, basePath.first) - } - - val stream = setupStream(context, name, relativePath, extension, tryResume) - if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode - - val resume = stream.resume!! - val fileStream = stream.fileStream!! - val fileLength = stream.fileLength!! - - // CONNECT - val connection: URLConnection = - URL(link.url.replace(" ", "%20")).openConnection() // IDK OLD PHONES BE WACK - - // SET CONNECTION SETTINGS - connection.connectTimeout = 10000 - connection.setRequestProperty("Accept-Encoding", "identity") - connection.setRequestProperty("user-agent", USER_AGENT) - if (link.referer.isNotEmpty()) connection.setRequestProperty("referer", link.referer) - - // extra stuff - connection.setRequestProperty( - "sec-ch-ua", - "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"" + var fileStream: OutputStream? = null + var requestStream: InputStream? = null + val metadata = DownloadMetaData( + totalBytes = 0, + bytesDownloaded = 0, + createNotificationCallback = createNotificationCallback, + id = parentId, ) + try { + // get the file path + val (baseFile, basePath) = context.getBasePath() + val displayName = getDisplayName(name, extension) + val relativePath = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath( + folder + ) else folder - connection.setRequestProperty("sec-ch-ua-mobile", "?0") - connection.setRequestProperty("accept", "*/*") - // dataSource.setRequestProperty("Sec-Fetch-Site", "none") //same-site - connection.setRequestProperty("sec-fetch-user", "?1") - connection.setRequestProperty("sec-fetch-mode", "navigate") - connection.setRequestProperty("sec-fetch-dest", "video") - link.headers.entries.forEach { - connection.setRequestProperty(it.key, it.value) - } + // set up the download file + val stream = setupStream(context, name, relativePath, extension, tryResume) + if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode + fileStream = stream.fileStream ?: return@withContext ERROR_UNKNOWN + val resume = stream.resume ?: return@withContext ERROR_UNKNOWN + val fileLength = stream.fileLength ?: return@withContext ERROR_UNKNOWN + val resumeAt = (if (resume) fileLength else 0) + metadata.bytesDownloaded = resumeAt + metadata.type = DownloadType.IsPending - if (resume) - connection.setRequestProperty("Range", "bytes=${fileLength}-") - val resumeLength = (if (resume) fileLength else 0) + // set up a connection + val request = app.get( + link.url.replace(" ", "%20"), + headers = link.headers + mapOf( + "Accept-Encoding" to "identity", + "accept" to "*/*", + "user-agent" to USER_AGENT, + "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", + "sec-fetch-mode" to "navigate", + "sec-fetch-dest" to "video", + "sec-fetch-user" to "?1", + "sec-ch-ua-mobile" to "?0", + ) + if (resumeAt > 0) mapOf("Range" to "bytes=${resumeAt}-") else emptyMap(), + referer = link.referer, + verify = false + ) - // ON CONNECTION - connection.connect() + // init variables + val contentLength = request.size ?: 0 + metadata.totalBytes = contentLength + resumeAt - val contentLength = try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // fuck android - connection.contentLengthLong - } else { - connection.getHeaderField("content-length").toLongOrNull() - ?: connection.contentLength.toLong() - } - } catch (e: Exception) { - logError(e) - 0L - } - val bytesTotal = contentLength + resumeLength - - if (extension == "mp4" && bytesTotal < 5000000) return@withContext ERROR_TOO_SMALL_CONNECTION // DATA IS LESS THAN 5MB, SOMETHING IS WRONG - - parentId?.let { - setKey( - KEY_DOWNLOAD_INFO, - it.toString(), + // save + metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( - bytesTotal, - relativePath ?: "", - displayName, - basePath = basePath.second + totalBytes = metadata.approxTotalBytes, + relativePath = relativePath ?: "", + displayName = displayName, + basePath = basePath ) ) + + // total length is less than 5mb, that is too short and something has gone wrong + if (extension == "mp4" && metadata.approxTotalBytes < 5000000) return@withContext ERROR_TOO_SMALL_CONNECTION + + // read the buffer into the filestream, this is equivalent of transferTo + requestStream = request.body.byteStream() + metadata.type = DownloadType.IsDownloading + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + while (requestStream.read(buffer, 0, DEFAULT_BUFFER_SIZE).also { read = it } >= 0) { + fileStream.write(buffer, 0, read) + + // wait until not paused + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped) break + metadata.addBytes(read.toLong()) + } + + if (metadata.type == DownloadType.IsStopped) { + return@withContext delete( + context, + name, + relativePath, + extension, + parentId, + baseFile + ) + } + + metadata.type = DownloadType.IsDone + return@withContext SUCCESS_DOWNLOAD_DONE + } catch (e: IOException) { + // some sort of IO error, this should not happened + // we just rethrow it + logError(e) + throw e + } catch (t: Throwable) { + // some sort of network error, will error + + // note that when failing we don't want to delete the file, + // only user interaction has that power + metadata.type = DownloadType.IsFailed + return@withContext ERROR_CONNECTION_ERROR + } finally { + fileStream?.closeQuietly() + requestStream?.closeQuietly() + metadata.close() } + } - // Could use connection.contentType for mime types when creating the file, - // however file is already created and players don't go of file type - // https://stackoverflow.com/questions/23714383/what-are-all-the-possible-values-for-http-content-type-header - // might receive application/octet-stream - /*if (!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) { - return ERROR_WRONG_CONTENT // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE - }*/ + @Throws + private suspend fun downloadHLS( + context: Context, + link: ExtractorLink, + name: String, + folder: String?, + parentId: Int?, + startIndex: Int?, + createNotificationCallback: (CreateNotificationMetadata) -> Unit, + parallelConnections: Int = 3 + ): Int = withContext(Dispatchers.IO) { + require(parallelConnections >= 1) - // READ DATA FROM CONNECTION - val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream) - val buffer = ByteArray(1024) - var count: Int - var bytesDownloaded = resumeLength + val metadata = DownloadMetaData( + createNotificationCallback = createNotificationCallback, + id = parentId + ) + val extension = "mp4" - var isPaused = false - var isStopped = false - var isDone = false - var isFailed = false + var fileStream: OutputStream? = null + try { + // the start .ts index + var startAt = startIndex ?: 0 - // TO NOT REUSE CODE - fun updateNotification() { - val type = when { - isDone -> DownloadType.IsDone - isStopped -> DownloadType.IsStopped - isFailed -> DownloadType.IsFailed - isPaused -> DownloadType.IsPaused - else -> DownloadType.IsDownloading - } + // set up the file data + val (baseFile, basePath) = context.getBasePath() + val relativePath = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath( + folder + ) else folder + val displayName = getDisplayName(name, extension) + val stream = setupStream(context, name, relativePath, extension, startAt > 0) + if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode + if (stream.resume != true) startAt = 0 + fileStream = stream.fileStream ?: return@withContext ERROR_UNKNOWN - parentId?.let { id -> - try { - downloadStatus[id] = type - downloadStatusEvent.invoke(Pair(id, type)) - downloadProgressEvent.invoke(Triple(id, bytesDownloaded, bytesTotal)) - } catch (e: Exception) { - // IDK MIGHT ERROR - } - } - - createNotificationCallback.invoke( - CreateNotificationMetadata( - type, - bytesDownloaded, - bytesTotal + // push the metadata + metadata.bytesDownloaded = stream.fileLength ?: 0 + metadata.hlsProgress = startAt + metadata.type = DownloadType.IsPending + metadata.setDownloadFileInfoTemplate( + DownloadedFileInfo( + totalBytes = 0, + relativePath = relativePath ?: "", + displayName = displayName, + basePath = basePath ) ) - /*createNotification( - context, - source, - link.name, - ep, - type, - bytesDownloaded, - bytesTotal - )*/ - } - val downloadEventListener = { event: Pair -> - if (event.first == parentId) { - when (event.second) { - DownloadActionType.Pause -> { - isPaused = true; updateNotification() + // do the initial get request to fetch the segments + val m3u8 = M3u8Helper.M3u8Stream( + link.url, link.quality, mapOf( + "Accept-Encoding" to "identity", + "accept" to "*/*", + "user-agent" to USER_AGENT, + ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap() + ) + val items = M3u8Helper2.hslLazy(listOf(m3u8)) + + metadata.hlsTotal = items.size + metadata.type = DownloadType.IsDownloading + + // does several connections in parallel instead of a regular for loop to improve + // download speed + (startAt until items.size).chunked(parallelConnections).forEach { subset -> + // wait until not paused + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped) return@forEach + + subset.amap { idx -> + idx to items.resolveLinkSafe(idx)?.also { bytes -> + metadata.addSegment(bytes.size.toLong()) } - - DownloadActionType.Stop -> { - isStopped = true; updateNotification() - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() - } - - DownloadActionType.Resume -> { - isPaused = false; updateNotification() + }.forEach { (idx, bytes) -> + if (bytes == null) { + metadata.type = DownloadType.IsFailed + return@withContext ERROR_CONNECTION_ERROR } + fileStream.write(bytes) + metadata.setWrittenSegment(idx) } } - } - if (parentId != null) - downloadEvent += downloadEventListener - - // UPDATE DOWNLOAD NOTIFICATION - val notificationCoroutine = main { - while (true) { - if (!isPaused) { - updateNotification() - } - for (i in 1..10) { - delay(100) - } - } - } - - // THE REAL READ - try { - while (true) { - count = connectionInputStream.read(buffer) - if (count < 0) break - bytesDownloaded += count - // downloadProgressEvent.invoke(Pair(id, bytesDownloaded)) // Updates too much for any UI to keep up with - while (isPaused) { - sleep(100) - if (isStopped) { - break - } - } - if (isStopped) { - break - } - fileStream.write(buffer, 0, count) - } - } catch (e: Exception) { - logError(e) - isFailed = true - updateNotification() - } - - // REMOVE AND EXIT ALL - fileStream.close() - connectionInputStream.close() - notificationCoroutine.cancel() - - try { - if (parentId != null) - downloadEvent -= downloadEventListener - } catch (e: Exception) { - logError(e) - } - - try { - parentId?.let { - downloadStatus.remove(it) - } - } catch (e: Exception) { - // IDK MIGHT ERROR - } - - // RETURN MESSAGE - return@withContext when { - isFailed -> { - parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) } - ERROR_CONNECTION_ERROR + if (metadata.type == DownloadType.IsStopped) { + return@withContext delete( + context, + name, + relativePath, + extension, + parentId, + baseFile + ) } - isStopped -> { - parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) } - deleteFile() - } - - else -> { - parentId?.let { id -> - downloadProgressEvent.invoke( - Triple( - id, - bytesDownloaded, - bytesTotal - ) - ) - } - isDone = true - updateNotification() - SUCCESS_DOWNLOAD_DONE - } + metadata.type = DownloadType.IsDone + return@withContext SUCCESS_DOWNLOAD_DONE + } catch (t: Throwable) { + logError(t) + metadata.type = DownloadType.IsFailed + return@withContext ERROR_UNKNOWN + } finally { + fileStream?.closeQuietly() + metadata.close() } } @@ -1107,192 +1268,6 @@ object VideoDownloadManager { return SUCCESS_STOPPED } - @Throws - private suspend fun downloadHLS( - context: Context, - link: ExtractorLink, - name: String, - folder: String?, - parentId: Int?, - startIndex: Int?, - createNotificationCallback: (CreateNotificationMetadata) -> Unit - ): Int = withContext(Dispatchers.IO) { - val extension = "mp4" - - val m3u8 = M3u8Helper.M3u8Stream( - link.url, link.quality, mapOf("referer" to link.referer) - ) - - var realIndex = startIndex ?: 0 - val basePath = context.getBasePath() - - val relativePath = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.first.isDownloadDir()) getRelativePath( - folder - ) else folder - - val stream = setupStream(context, name, relativePath, extension, realIndex > 0) - if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode - - if (stream.resume != true) realIndex = 0 - val fileLengthAdd = stream.fileLength ?: 0 - val items = M3u8Helper2.hslLazy(listOf(m3u8)) - - val displayName = getDisplayName(name, extension) - - val fileStream = stream.fileStream!! - - var isDone = false - var isFailed = false - var isPaused = false - var bytesDownloaded = fileLengthAdd - var tsProgress: Long = realIndex.toLong() + 1 // we don't want div by zero - val totalTs: Long = items.size.toLong() - - fun deleteFile(): Int { - return delete(context, name, relativePath, extension, parentId, basePath.first) - } - - fun updateInfo() { - setKey( - KEY_DOWNLOAD_INFO, - (parentId ?: return).toString(), - DownloadedFileInfo( - // approx bytes - totalBytes = (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - relativePath = relativePath ?: "", - displayName = displayName, - extraInfo = tsProgress.toString(), - basePath = basePath.second - ) - ) - } - - updateInfo() - - fun updateNotification() { - val type = when { - isDone -> DownloadType.IsDone - isFailed -> DownloadType.IsFailed - isPaused -> DownloadType.IsPaused - else -> DownloadType.IsDownloading - } - - parentId?.let { id -> - try { - downloadStatus[id] = type - downloadStatusEvent.invoke(Pair(id, type)) - downloadProgressEvent.invoke( - Triple( - id, - bytesDownloaded, - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - ) - ) - } catch (_: Throwable) {} - } - - createNotificationCallback.invoke( - CreateNotificationMetadata( - type, - bytesDownloaded, - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - tsProgress, - totalTs - ) - ) - } - - val notificationCoroutine = main { - while (true) { - if (!isDone) { - updateNotification() - } - for (i in 1..10) { - delay(100) - } - } - } - - val downloadEventListener = { event: Pair -> - if (event.first == parentId) { - when (event.second) { - DownloadActionType.Stop -> { - isFailed = true - } - - DownloadActionType.Pause -> { - isPaused = true - } - - DownloadActionType.Resume -> { - isPaused = false - } - } - updateNotification() - } - } - - fun closeAll() { - try { - if (parentId != null) - downloadEvent -= downloadEventListener - } catch (t: Throwable) { - logError(t) - } - try { - parentId?.let { - downloadStatus.remove(it) - } - } catch (t: Throwable) { - logError(t) - } - notificationCoroutine.cancel() - } - - if (parentId != null) - downloadEvent += downloadEventListener - - fun onFailed() { - fileStream.close() - deleteFile() - updateNotification() - closeAll() - } - - for (idx in realIndex until items.size) { - while (isPaused) { - if (isFailed) { - onFailed() - return@withContext SUCCESS_STOPPED - } - delay(100) - } - - if (isFailed) { - onFailed() - return@withContext SUCCESS_STOPPED - } - - val bytes = items.resolveLinkSafe(idx) ?: run { - isFailed = true - onFailed() - return@withContext ERROR_CONNECTION_ERROR - } - - fileStream.write(bytes) - tsProgress = idx.toLong() + 1 - bytesDownloaded += bytes.size.toLong() - updateInfo() - } - isDone = true - fileStream.close() - updateNotification() - - closeAll() - updateInfo() - return@withContext SUCCESS_DOWNLOAD_DONE - } fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { return getFileName(context, metadata.name, metadata.episode, metadata.season) @@ -1353,22 +1328,30 @@ object VideoDownloadManager { )?.extraInfo?.toIntOrNull() } else null return suspendSafeApiCall { - downloadHLS(context, link, name, folder, ep.id, startIndex) { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback, - meta.hlsProgress, - meta.hlsTotal - ) + downloadHLS( + context, + link, + name, + folder, + ep.id, + startIndex, + createNotificationCallback = { meta -> + main { + createNotification( + context, + source, + link.name, + ep, + meta.type, + meta.bytesDownloaded, + meta.bytesTotal, + notificationCallback, + meta.hlsProgress, + meta.hlsTotal + ) + } } - } + ) }.also { extractorJob.cancel() } ?: ERROR_UNKNOWN @@ -1392,7 +1375,7 @@ object VideoDownloadManager { }.also { extractorJob.cancel() } ?: ERROR_UNKNOWN } - fun downloadCheck( + suspend fun downloadCheck( context: Context, notificationCallback: (Int, Notification) -> Unit, ): Int? { if (currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0) { @@ -1407,42 +1390,55 @@ object VideoDownloadManager { currentDownloads.add(id) - main { - try { - for (index in (pkg.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = pkg.linkIndex == index + try { + for (index in (pkg.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = pkg.linkIndex == index - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) + setKey( + KEY_RESUME_PACKAGES, + id.toString(), + DownloadResumePackage(item, index) + ) + + var connectionResult = + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ) + //.also { println("Single episode finished with return code: $it") } + + // retry every link at least once + if (connectionResult <= 0) { + connectionResult = downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + true ) - val connectionResult = withContext(Dispatchers.IO) { - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ).also { println("Single episode finished with return code: $it") } - } - if (connectionResult != null && connectionResult > 0) { // SUCCESS - removeKey(KEY_RESUME_PACKAGES, id.toString()) - break - } else if (index == item.links.lastIndex) { - downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) - } } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the workmanager - downloadCheckUsingWorker(context) + + if (connectionResult > 0) { // SUCCESS + removeKey(KEY_RESUME_PACKAGES, id.toString()) + break + } else if (index == item.links.lastIndex) { + downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) + } } + } catch (e: Exception) { + logError(e) + } finally { + currentDownloads.remove(id) + // Because otherwise notifications will not get caught by the work manager + downloadCheckUsingWorker(context) } } return null @@ -1538,26 +1534,13 @@ object VideoDownloadManager { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - fun downloadFromResume( + suspend fun downloadFromResume( context: Context, pkg: DownloadResumePackage, notificationCallback: (Int, Notification) -> Unit, setKey: Boolean = true ) { if (!currentDownloads.any { it == pkg.item.ep.id }) { -// if (currentDownloads.size == maxConcurrentDownloads) { -// main { -//// showToast( // can be replaced with regular Toast -//// context, -//// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${ -//// context.getString( -//// R.string.queued -//// ) -//// }", -//// Toast.LENGTH_SHORT -//// ) -// } -// } downloadQueue.addLast(pkg) downloadCheck(context, notificationCallback) if (setKey) saveQueue() @@ -1590,7 +1573,7 @@ object VideoDownloadManager { return false }*/ - fun downloadEpisode( + suspend fun downloadEpisode( context: Context?, source: String?, folder: String?, @@ -1599,13 +1582,12 @@ object VideoDownloadManager { notificationCallback: (Int, Notification) -> Unit, ) { if (context == null) return - if (links.isNotEmpty()) { - downloadFromResume( - context, - DownloadResumePackage(DownloadItem(source, folder, ep, links), null), - notificationCallback - ) - } + if (links.isEmpty()) return + downloadFromResume( + context, + DownloadResumePackage(DownloadItem(source, folder, ep, links), null), + notificationCallback + ) } /** Worker stuff */ diff --git a/app/src/main/res/drawable/baseline_stop_24.xml b/app/src/main/res/drawable/baseline_stop_24.xml new file mode 100644 index 00000000..100cb1fc --- /dev/null +++ b/app/src/main/res/drawable/baseline_stop_24.xml @@ -0,0 +1,10 @@ + + + From a05616e3e8c6c0373f88a7c6dcc022d42ca7188d Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 19 Aug 2023 01:37:48 +0200 Subject: [PATCH 11/39] fix --- .../cloudstream3/extractors/Mp4Upload.kt | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt index 93a280ed..e746b286 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt @@ -10,24 +10,39 @@ open class Mp4Upload : ExtractorApi() { override var name = "Mp4Upload" override var mainUrl = "https://www.mp4upload.com" private val srcRegex = Regex("""player\.src\("(.*?)"""") - override val requiresReferer = true + private val srcRegex2 = Regex("""player\.src\([\w\W]*src: "(.*?)"""") + override val requiresReferer = true + private val idMatch = Regex("""mp4upload\.com/(embed-|)([A-Za-z0-9]*)""") override suspend fun getUrl(url: String, referer: String?): List? { - with(app.get(url)) { - getAndUnpack(this.text).let { unpackedText -> - val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull() - srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link -> - return listOf( - ExtractorLink( - name, - name, - link, - url, - quality ?: Qualities.Unknown.value, - ) - ) - } - } + val realUrl = idMatch.find(url)?.groupValues?.get(2)?.let { id -> + "$mainUrl/embed-$id.html" + } ?: url + val response = app.get(realUrl) + val unpackedText = getAndUnpack(response.text) + val quality = + unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull() + srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link -> + return listOf( + ExtractorLink( + name, + name, + link, + url, + quality ?: Qualities.Unknown.value, + ) + ) + } + srcRegex2.find(unpackedText)?.groupValues?.get(1)?.let { link -> + return listOf( + ExtractorLink( + name, + name, + link, + url, + quality ?: Qualities.Unknown.value, + ) + ) } return null } From 35e1b8b4dcbe5172afc8f4cf23a673a84f99c194 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 19 Aug 2023 01:38:40 +0200 Subject: [PATCH 12/39] bump --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f72ec0b0..71015e31 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,7 +52,7 @@ android { targetSdk = 33 versionCode = 59 - versionName = "4.1.3" + versionName = "4.1.4" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") From e20e3dcfd3ce450b0efe61d64db4a34413652c72 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 19 Aug 2023 04:46:47 +0200 Subject: [PATCH 13/39] fixed some bugs caused by new download update --- app/build.gradle.kts | 2 +- .../utils/DownloadFileWorkManager.kt | 17 +- .../utils/VideoDownloadManager.kt | 271 ++++++++++-------- 3 files changed, 165 insertions(+), 125 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 71015e31..708a2083 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,7 +52,7 @@ android { targetSdk = 33 versionCode = 59 - versionName = "4.1.4" + versionName = "4.1.5" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index c1eb649b..aa424c08 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -7,6 +7,7 @@ import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO @@ -25,15 +26,16 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo override suspend fun doWork(): Result { val key = workerParams.inputData.getString("key") try { - println("KEY $key") if (key == DOWNLOAD_CHECK) { - downloadCheck(applicationContext, ::handleNotification)?.let { - awaitDownload(it) - } + downloadCheck(applicationContext, ::handleNotification) } else if (key != null) { - val info = applicationContext.getKey(WORK_KEY_INFO, key) + val info = + applicationContext.getKey(WORK_KEY_INFO, key) val pkg = - applicationContext.getKey(WORK_KEY_PACKAGE, key) + applicationContext.getKey( + WORK_KEY_PACKAGE, + key + ) if (info != null) { downloadEpisode( applicationContext, @@ -43,10 +45,8 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo info.links, ::handleNotification ) - awaitDownload(info.ep.id) } else if (pkg != null) { downloadFromResume(applicationContext, pkg, ::handleNotification) - awaitDownload(pkg.item.ep.id) } removeKeys(key) } @@ -73,6 +73,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> { isDone = true } + else -> Unit } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 0334103f..ef0d9d8a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -26,6 +26,7 @@ import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType @@ -723,7 +724,7 @@ object VideoDownloadManager { var hlsTotal: Int? = null, // this is how many segments that has been written to the file // will always be <= hlsProgress as we may keep some in a buffer - var hlsWrittenProgress: Long = 0, + var hlsWrittenProgress: Int = 0, // this is used for copy with metadata on how much we have downloaded for setKey private var downloadFileInfoTemplate: DownloadedFileInfo? = null @@ -798,6 +799,15 @@ object VideoDownloadManager { notify() } + fun onDelete() { + bytesDownloaded = 0 + hlsWrittenProgress = 0 + hlsProgress = 0 + + //internalType = DownloadType.IsStopped + notify() + } + companion object { const val UPDATE_RATE_MS: Long = 1000L } @@ -842,6 +852,9 @@ object VideoDownloadManager { } } catch (t: Throwable) { logError(t) + if (BuildConfig.DEBUG) { + throw t + } } } @@ -866,7 +879,7 @@ object VideoDownloadManager { } fun setWrittenSegment(segmentIndex: Int) { - hlsWrittenProgress = segmentIndex.toLong() + 1L + hlsWrittenProgress = segmentIndex + 1 } } @@ -916,16 +929,18 @@ object VideoDownloadManager { // set up a connection val request = app.get( link.url.replace(" ", "%20"), - headers = link.headers + mapOf( - "Accept-Encoding" to "identity", - "accept" to "*/*", - "user-agent" to USER_AGENT, - "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", - "sec-fetch-mode" to "navigate", - "sec-fetch-dest" to "video", - "sec-fetch-user" to "?1", - "sec-ch-ua-mobile" to "?0", - ) + if (resumeAt > 0) mapOf("Range" to "bytes=${resumeAt}-") else emptyMap(), + headers = link.headers.appendAndDontOverride( + mapOf( + "Accept-Encoding" to "identity", + "accept" to "*/*", + "user-agent" to USER_AGENT, + "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", + "sec-fetch-mode" to "navigate", + "sec-fetch-dest" to "video", + "sec-fetch-user" to "?1", + "sec-ch-ua-mobile" to "?0", + ) + if (resumeAt > 0) mapOf("Range" to "bytes=${resumeAt}-") else emptyMap() + ), referer = link.referer, verify = false ) @@ -964,14 +979,14 @@ object VideoDownloadManager { } if (metadata.type == DownloadType.IsStopped) { - return@withContext delete( - context, - name, - relativePath, - extension, - parentId, - baseFile - ) + // we need to close before delete + fileStream.closeQuietly() + metadata.onDelete() + if (deleteFile(context, baseFile, relativePath ?: "", displayName)) { + return@withContext SUCCESS_STOPPED + } else { + return@withContext ERROR_DELETING_FILE + } } metadata.type = DownloadType.IsDone @@ -995,6 +1010,18 @@ object VideoDownloadManager { } } + /** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + private fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } @Throws private suspend fun downloadHLS( @@ -1047,11 +1074,13 @@ object VideoDownloadManager { // do the initial get request to fetch the segments val m3u8 = M3u8Helper.M3u8Stream( - link.url, link.quality, mapOf( - "Accept-Encoding" to "identity", - "accept" to "*/*", - "user-agent" to USER_AGENT, - ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap() + link.url, link.quality, link.headers.appendAndDontOverride( + mapOf( + "Accept-Encoding" to "identity", + "accept" to "*/*", + "user-agent" to USER_AGENT, + ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap() + ) ) val items = M3u8Helper2.hslLazy(listOf(m3u8)) @@ -1081,14 +1110,14 @@ object VideoDownloadManager { } if (metadata.type == DownloadType.IsStopped) { - return@withContext delete( - context, - name, - relativePath, - extension, - parentId, - baseFile - ) + // we need to close before delete + fileStream.closeQuietly() + metadata.onDelete() + if (deleteFile(context, baseFile, relativePath ?: "", displayName)) { + return@withContext SUCCESS_STOPPED + } else { + return@withContext ERROR_DELETING_FILE + } } metadata.type = DownloadType.IsDone @@ -1194,7 +1223,7 @@ object VideoDownloadManager { return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace( '/', File.separatorChar - ) + ).replace("${File.separatorChar}${File.separatorChar}", File.separatorChar.toString()) } /** @@ -1224,7 +1253,7 @@ object VideoDownloadManager { return this != null && this.filePath == getDownloadDir()?.filePath } - private fun delete( + /*private fun delete( context: Context, name: String, folder: String?, @@ -1235,7 +1264,7 @@ object VideoDownloadManager { val displayName = getDisplayName(name, extension) // delete all subtitle files - if (extension == "mp4") { + if (extension != "vtt" && extension != "srt") { try { delete(context, name, folder, "vtt", parentId, basePath) delete(context, name, folder, "srt", parentId, basePath) @@ -1248,9 +1277,9 @@ object VideoDownloadManager { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.isDownloadDir()) { val relativePath = getRelativePath(folder) val lastContent = - context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName) - if (lastContent != null) { - context.contentResolver.delete(lastContent, null, null) + context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName) ?: return ERROR_DELETING_FILE + if(context.contentResolver.delete(lastContent, null, null) <= 0) { + return ERROR_DELETING_FILE } } else { val dir = basePath?.gotoDir(folder) @@ -1260,13 +1289,12 @@ object VideoDownloadManager { // Cleans up empty directory if (dir.listFiles()?.isEmpty() == true) dir.delete() } -// } parentId?.let { downloadDeleteEvent.invoke(parentId) } } return SUCCESS_STOPPED - } + }*/ fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { @@ -1377,71 +1405,71 @@ object VideoDownloadManager { suspend fun downloadCheck( context: Context, notificationCallback: (Int, Notification) -> Unit, - ): Int? { - if (currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0) { - val pkg = downloadQueue.removeFirst() - val item = pkg.item - val id = item.ep.id - if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(Pair(id, DownloadActionType.Resume)) - /** ID needs to be returned to the work-manager to properly await notification */ - return id - } + ) { + if (!(currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0)) return - currentDownloads.add(id) - - try { - for (index in (pkg.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = pkg.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - - var connectionResult = - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ) - //.also { println("Single episode finished with return code: $it") } - - // retry every link at least once - if (connectionResult <= 0) { - connectionResult = downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - true - ) - } - - if (connectionResult > 0) { // SUCCESS - removeKey(KEY_RESUME_PACKAGES, id.toString()) - break - } else if (index == item.links.lastIndex) { - downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) - } - } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the work manager - downloadCheckUsingWorker(context) - } + val pkg = downloadQueue.removeFirst() + val item = pkg.item + val id = item.ep.id + if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(Pair(id, DownloadActionType.Resume)) + /** ID needs to be returned to the work-manager to properly await notification */ + // return id } - return null + + currentDownloads.add(id) + try { + for (index in (pkg.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = pkg.linkIndex == index + + setKey( + KEY_RESUME_PACKAGES, + id.toString(), + DownloadResumePackage(item, index) + ) + + var connectionResult = + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ) + //.also { println("Single episode finished with return code: $it") } + + // retry every link at least once + if (connectionResult <= 0) { + connectionResult = downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + true + ) + } + + if (connectionResult > 0) { // SUCCESS + removeKey(KEY_RESUME_PACKAGES, id.toString()) + break + } else if (index == item.links.lastIndex) { + downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) + } + } + } catch (e: Exception) { + logError(e) + } finally { + currentDownloads.remove(id) + // Because otherwise notifications will not get caught by the work manager + downloadCheckUsingWorker(context) + } + + // return id } fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { @@ -1500,23 +1528,21 @@ object VideoDownloadManager { return success } - private fun deleteFile(context: Context, id: Int): Boolean { - val info = - context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false - downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(Pair(id, DownloadType.IsStopped)) - downloadDeleteEvent.invoke(id) - val base = basePathToFile(context, info.basePath) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { + private fun deleteFile( + context: Context, + folder: UniFile?, + relativePath: String, + displayName: String + ): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && folder.isDownloadDir()) { val cr = context.contentResolver ?: return false val fileUri = - cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) + cr.getExistingDownloadUriOrNullQ(relativePath, displayName) ?: return true // FILE NOT FOUND, ALREADY DELETED return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0 } else { - val file = base?.gotoDir(info.relativePath)?.findFile(info.displayName) + val file = folder?.gotoDir(relativePath)?.findFile(displayName) // val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) // val dFile = File(normalPath) if (file?.exists() != true) return true @@ -1530,6 +1556,17 @@ object VideoDownloadManager { } } + private fun deleteFile(context: Context, id: Int): Boolean { + val info = + context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false + downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) + downloadProgressEvent.invoke(Triple(id, 0, 0)) + downloadStatusEvent.invoke(id to DownloadType.IsStopped) + downloadDeleteEvent.invoke(id) + val base = basePathToFile(context, info.basePath) + return deleteFile(context, base, info.relativePath, info.displayName) + } + fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } @@ -1544,10 +1581,12 @@ object VideoDownloadManager { downloadQueue.addLast(pkg) downloadCheck(context, notificationCallback) if (setKey) saveQueue() + //ret } else { - downloadEvent.invoke( + downloadEvent( Pair(pkg.item.ep.id, DownloadActionType.Resume) ) + //null } } From f571596bbc69d1fca24fb99dcef86227f03a4a80 Mon Sep 17 00:00:00 2001 From: IndusAryan <125901294+IndusAryan@users.noreply.github.com> Date: Sat, 19 Aug 2023 19:34:21 +0530 Subject: [PATCH 14/39] fix: expand resume watching sheet and ft: ripple on profile drawable when pressed (#566) * add ripple to profile icon on home * Update HomeParentItemAdapterPreview.kt --- .../cloudstream3/ui/home/HomeParentItemAdapterPreview.kt | 4 ++-- app/src/main/res/layout/fragment_home_head.xml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 943f784a..13497a99 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -539,7 +539,7 @@ class HomeParentItemAdapterPreview( resumeAdapter.updateList(resumeWatching) if (binding is FragmentHomeHeadBinding) { - binding.homeBookmarkParentItemTitle.setOnClickListener { + binding.homeWatchParentItemTitle.setOnClickListener { viewModel.popup( HomeViewModel.ExpandableHomepageList( HomePageList( @@ -578,4 +578,4 @@ class HomeParentItemAdapterPreview( } } } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/fragment_home_head.xml b/app/src/main/res/layout/fragment_home_head.xml index e13b96a8..90386ccf 100644 --- a/app/src/main/res/layout/fragment_home_head.xml +++ b/app/src/main/res/layout/fragment_home_head.xml @@ -61,7 +61,7 @@ android:layout_height="match_parent" android:layout_gravity="center" android:layout_marginStart="-50dp" - android:background="?android:attr/selectableItemBackgroundBorderless" + android:foreground="?android:attr/selectableItemBackgroundBorderless" android:contentDescription="@string/account" android:nextFocusLeft="@id/home_search" android:padding="10dp" @@ -288,4 +288,4 @@ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listitem="@layout/home_result_grid" /> - \ No newline at end of file + From b3abf1e45fbf2a532b551f9d69dbf7c674765ba7 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 19 Aug 2023 17:03:27 +0200 Subject: [PATCH 15/39] fixed decryption --- .../cloudstream3/utils/M3u8Helper.kt | 24 ++++++++----------- .../utils/VideoDownloadManager.kt | 2 ++ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt index 1fb3a72d..5c0b45de 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt @@ -88,21 +88,17 @@ object M3u8Helper2 { } } - private val defaultIvGen = sequence { - var initial = 1 + private fun defaultIv(index: Int) : ByteArray { + return toBytes16Big(index+1) + } - while (true) { - yield(toBytes16Big(initial)) - ++initial - } - }.iterator() - - fun getDecrypter( + fun getDecrypted( secretKey: ByteArray, data: ByteArray, - iv: ByteArray = "".toByteArray() + iv: ByteArray = byteArrayOf(), + index : Int, ): ByteArray { - val ivKey = if (iv.isEmpty()) defaultIvGen.next() else iv + val ivKey = if (iv.isEmpty()) defaultIv(index) else iv val c = Cipher.getInstance("AES/CBC/PKCS5Padding") val skSpec = SecretKeySpec(secretKey, "AES") val ivSpec = IvParameterSpec(ivKey) @@ -234,7 +230,7 @@ object M3u8Helper2 { if (tsData.isEmpty()) throw ErrorLoadingException("no data") return if (isEncrypted) { - getDecrypter(encryptionData, tsData, encryptionIv) + getDecrypted(encryptionData, tsData, encryptionIv, index) } else { tsData } @@ -272,13 +268,13 @@ object M3u8Helper2 { val match = ENCRYPTION_URL_IV_REGEX.find(m3u8Response)!!.groupValues - var encryptionUri = match[1] + var encryptionUri = match[2] if (isNotCompleteUrl(encryptionUri)) { encryptionUri = "${getParentLink(secondSelection.streamUrl)}/$encryptionUri" } - encryptionIv = match[2].toByteArray() + encryptionIv = match[3].toByteArray() val encryptionKeyResponse = app.get(encryptionUri, headers = headers, verify = false) encryptionData = encryptionKeyResponse.body.bytes() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index ef0d9d8a..dc3eaa25 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -803,6 +803,8 @@ object VideoDownloadManager { bytesDownloaded = 0 hlsWrittenProgress = 0 hlsProgress = 0 + if (id != null) + downloadDeleteEvent(id) //internalType = DownloadType.IsStopped notify() From 98b641714073e6bb84f74bd9ea3e19a03ed857aa Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 19 Aug 2023 21:37:14 +0200 Subject: [PATCH 16/39] made downloader faster with parallel downloads --- .../ui/result/ResultViewModel2.kt | 6 +- .../utils/VideoDownloadManager.kt | 438 ++++++++++++++++-- 2 files changed, 395 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 2fe3b012..bdd27091 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -593,10 +593,8 @@ class ResultViewModel2 : ViewModel() { folder, if (link.url.contains(".srt")) ".srt" else "vtt", false, - null - ) { - // no notification - } + null, createNotificationCallback = {} + ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index dc3eaa25..d8ef7e85 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -40,14 +40,20 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.internal.closeQuietly import java.io.Closeable import java.io.File import java.io.IOException -import java.io.InputStream import java.io.OutputStream import java.net.URL import java.util.* @@ -710,6 +716,8 @@ object VideoDownloadManager { data class DownloadMetaData( private val id: Int?, var bytesDownloaded: Long = 0, + var bytesWritten: Long = 0, + var totalBytes: Long? = null, // notification metadata @@ -732,10 +740,21 @@ object VideoDownloadManager { val approxTotalBytes: Long get() = totalBytes ?: hlsTotal?.let { total -> (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() - } ?: 0L + } ?: bytesDownloaded private val isHLS get() = hlsTotal != null + private var stopListener: (() -> Unit)? = null + + /** on cancel button pressed or failed invoke this once and only once */ + fun setOnStop(callback: (() -> Unit)) { + stopListener = callback + } + + fun removeStopListener() { + stopListener = null + } + private val downloadEventListener = { event: Pair -> if (event.first == id) { when (event.second) { @@ -747,6 +766,8 @@ object VideoDownloadManager { type = DownloadType.IsStopped removeKey(KEY_RESUME_PACKAGES, event.first.toString()) saveQueue() + stopListener?.invoke() + stopListener = null } DownloadActionType.Resume -> { @@ -783,13 +804,14 @@ object VideoDownloadManager { override fun close() { // as we may need to resume hls downloads, we save the current written index - if (isHLS) { + if (isHLS || totalBytes == null) { updateFileInfo() } if (id != null) { downloadEvent -= downloadEventListener downloadStatus -= id } + stopListener = null } var type @@ -846,6 +868,11 @@ object VideoDownloadManager { updateFileInfo() } + if (internalType == DownloadType.IsStopped || internalType == DownloadType.IsFailed) { + stopListener?.invoke() + stopListener = null + } + // push all events, this *should* not crash, TODO MUTEX? if (id != null) { downloadStatus[id] = type @@ -874,6 +901,10 @@ object VideoDownloadManager { if (type == DownloadType.IsDownloading) checkNotification() } + fun addBytesWritten(length: Long) { + bytesWritten += length + } + /** adds the length + hsl progress and pushes a notification if necessary */ fun addSegment(length: Long) { hlsProgress += 1 @@ -885,6 +916,173 @@ object VideoDownloadManager { } } + /** bytes have the size end-start where the byte range is [start,end) + * note that ByteArray is a pointer and therefore cant be stored without cloning it */ + data class LazyStreamDownloadResponse( + val bytes: ByteArray, + val startByte: Long, + val endByte: Long, + ) { + val size get() = endByte - startByte + + override fun toString(): String { + return "$startByte->$endByte" + } + + override fun equals(other: Any?): Boolean { + if (other !is LazyStreamDownloadResponse) return false + return other.startByte == startByte && other.endByte == endByte + } + + override fun hashCode(): Int { + return Objects.hash(startByte, endByte) + } + } + + data class LazyStreamDownloadData( + private val url: String, + private val headers: Map, + private val referer: String, + /** This specifies where chunck i starts and ends, + * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} + * where out of bounds => bytes=${chuckStartByte[ i ]}- */ + private val chuckStartByte: LongArray, + val totalLength: Long?, + val downloadLength: Long?, + val chuckSize: Long, + val bufferSize: Int, + ) { + val size get() = chuckStartByte.size + + /** returns what byte it has downloaded, + * so start at 10 and download 4 bytes = return 14 + * + * the range is [startByte, endByte) to be able to do [a, b) [b, c) ect + * + * [a, null) will return inclusive to eof = [a, eof] + * + * throws an error if initial get request fails, can be specified as return startByte + * */ + @Throws + private suspend fun resolve( + startByte: Long, + endByte: Long?, + callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) + ): Long = withContext(Dispatchers.IO) { + var currentByte: Long = startByte + val stopAt = endByte ?: Long.MAX_VALUE + if (currentByte >= stopAt) return@withContext currentByte + + val request = app.get( + url, + headers = headers + mapOf( + // range header is inclusive so [startByte, endByte-1] = [startByte, endByte) + // if nothing at end the server will continue until eof + "Range" to "bytes=$startByte-" // ${endByte?.minus(1)?.toString() ?: "" } + ), + referer = referer, + verify = false + ) + val requestStream = request.body.byteStream() + + val buffer = ByteArray(bufferSize) + var read: Int + + try { + while (requestStream.read(buffer, 0, bufferSize).also { read = it } >= 0) { + val start = currentByte + currentByte += read.toLong() + + // this stops overflow + if (currentByte >= stopAt) { + callback(LazyStreamDownloadResponse(buffer, start, stopAt)) + break + } else { + callback(LazyStreamDownloadResponse(buffer, start, currentByte)) + } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logError(t) + } finally { + requestStream.closeQuietly() + } + + return@withContext currentByte + } + + /** retries the resolve n times and returns true if successful */ + suspend fun resolveSafe( + index: Int, + retries: Int = 3, + callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) + ): Boolean { + var start = chuckStartByte.getOrNull(index) ?: return false + val end = chuckStartByte.getOrNull(index + 1) + + for (i in 0 until retries) { + try { + // in case + start = resolve(start, end, callback) + // no end defined, so we don't care exactly where it ended + if (end == null) return true + // we have download more or exactly what we needed + if (start >= end) return true + } catch (e: IllegalStateException) { + return false + } catch (e: CancellationException) { + return false + } catch (t: Throwable) { + continue + } + } + return false + } + + } + + @Throws + suspend fun streamLazy( + url: String, + headers: Map, + referer: String, + startByte: Long, + /** how many bytes every connection should be, by default it is 10 MiB */ + chuckSize: Long = (1 shl 20) * 10, + /** maximum bytes in the buffer that responds */ + bufferSize: Int = DEFAULT_BUFFER_SIZE + ): LazyStreamDownloadData { + // we don't want to make a separate connection for every 1kb + require(chuckSize > 1000) + + val contentLength = + app.head(url = url, headers = headers, referer = referer, verify = false).size + + var downloadLength: Long? = null + var totalLength: Long? = null + + val ranges = if (contentLength == null) { + LongArray(1) { startByte } + } else { + downloadLength = contentLength - startByte + totalLength = contentLength + LongArray((downloadLength / chuckSize).toInt()) { idx -> + startByte + idx * chuckSize + } + } + return LazyStreamDownloadData( + url = url, + headers = headers, + referer = referer, + chuckStartByte = ranges, + downloadLength = downloadLength, + totalLength = totalLength, + chuckSize = chuckSize, + bufferSize = bufferSize + ) + } + @Throws suspend fun downloadThing( context: Context, @@ -895,6 +1093,7 @@ object VideoDownloadManager { tryResume: Boolean, parentId: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit, + parallelConnections: Int = 3 ): Int = withContext(Dispatchers.IO) { // we cant download torrents with this implementation, aria2c might be used in the future if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) { @@ -902,7 +1101,7 @@ object VideoDownloadManager { } var fileStream: OutputStream? = null - var requestStream: InputStream? = null + //var requestStream: InputStream? = null val metadata = DownloadMetaData( totalBytes = 0, bytesDownloaded = 0, @@ -926,11 +1125,13 @@ object VideoDownloadManager { val fileLength = stream.fileLength ?: return@withContext ERROR_UNKNOWN val resumeAt = (if (resume) fileLength else 0) metadata.bytesDownloaded = resumeAt + metadata.bytesWritten = resumeAt metadata.type = DownloadType.IsPending - // set up a connection - val request = app.get( - link.url.replace(" ", "%20"), + val items = streamLazy( + url = link.url.replace(" ", "%20"), + referer = link.referer, + startByte = resumeAt, headers = link.headers.appendAndDontOverride( mapOf( "Accept-Encoding" to "identity", @@ -941,17 +1142,12 @@ object VideoDownloadManager { "sec-fetch-dest" to "video", "sec-fetch-user" to "?1", "sec-ch-ua-mobile" to "?0", - ) + if (resumeAt > 0) mapOf("Range" to "bytes=${resumeAt}-") else emptyMap() - ), - referer = link.referer, - verify = false + ) + ) ) - // init variables - val contentLength = request.size ?: 0 - metadata.totalBytes = contentLength + resumeAt - - // save + metadata.totalBytes = items.totalLength + metadata.type = DownloadType.IsDownloading metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( totalBytes = metadata.approxTotalBytes, @@ -961,23 +1157,166 @@ object VideoDownloadManager { ) ) - // total length is less than 5mb, that is too short and something has gone wrong - if (extension == "mp4" && metadata.approxTotalBytes < 5000000) return@withContext ERROR_TOO_SMALL_CONNECTION + val currentMutex = Mutex() + val current = (0 until items.size).iterator() - // read the buffer into the filestream, this is equivalent of transferTo - requestStream = request.body.byteStream() - metadata.type = DownloadType.IsDownloading + val fileMutex = Mutex() + // start to data + val pendingData: HashMap = + hashMapOf() - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var read: Int - while (requestStream.read(buffer, 0, DEFAULT_BUFFER_SIZE).also { read = it } >= 0) { - fileStream.write(buffer, 0, read) + val jobs = (0 until parallelConnections).map { + launch { - // wait until not paused - while (metadata.type == DownloadType.IsPaused) delay(100) - // if stopped then break to delete - if (metadata.type == DownloadType.IsStopped) break - metadata.addBytes(read.toLong()) + // this may seem a bit complex but it more or less acts as a queue system + // imagine we do the downloading [0,3] and it response in the order 0,2,3,1 + // file: [_,_,_,_] queue: [_,_,_,_] Initial condition + // file: [X,_,_,_] queue: [_,_,_,_] + added 0 directly to file + // file: [X,_,_,_] queue: [_,_,X,_] + added 2 to queue + // file: [X,_,_,_] queue: [_,_,X,X] + added 3 to queue + // file: [X,X,_,_] queue: [_,_,X,X] + added 1 directly to file + // file: [X,X,X,X] queue: [_,_,_,_] write the queue and remove from it + + val callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) = + callback@{ response -> + if (!isActive) return@callback + fileMutex.withLock { + // wait until not paused + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then throw + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed) { + this.cancel() + return@callback + } + + val responseSize = response.size + metadata.addBytes(response.size) + + if (response.startByte == metadata.bytesWritten) { + // if we are first in the queue then write it directly + fileStream.write( + response.bytes, + 0, + responseSize.toInt() + ) + metadata.addBytesWritten(responseSize) + } else { + // otherwise append to queue, we need to clone the bytes as they will be overridden otherwise + pendingData[response.startByte] = + response.copy(bytes = response.bytes.clone()) + } + + while (true) { + // remove the current queue start, so no possibility of + // while(true) { continue } in case size = 0, and removed extra + // garbage + val pending = pendingData.remove(metadata.bytesWritten) ?: break + + val size = pending.size + + fileStream.write( + pending.bytes, + 0, + size.toInt() + ) + metadata.addBytesWritten(size) + } + } + } + + // this will take up the first available job and resolve + while (true) { + if (!isActive) return@launch + fileMutex.withLock { + if (metadata.type == DownloadType.IsStopped) return@launch + } + + // just in case, we never want this to fail due to multithreading + val index = currentMutex.withLock { + if (!current.hasNext()) return@launch + current.nextInt() + } + + // in case something has gone wrong set to failed if the fail is not caused by + // user cancellation + if (!items.resolveSafe(index, callback = callback)) { + fileMutex.withLock { + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } + return@launch + } + } + } + } + + // fast stop as the jobs may be in a slow request + metadata.setOnStop { + jobs.forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + jobs.forEach { it.join() } + + // jobs are finished so we don't want to stop them anymore + metadata.removeStopListener() + + // set up a connection + //val request = app.get( + // link.url.replace(" ", "%20"), + // headers = link.headers.appendAndDontOverride( + // mapOf( + // "Accept-Encoding" to "identity", + // "accept" to "*/*", + // "user-agent" to USER_AGENT, + // "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", + // "sec-fetch-mode" to "navigate", + // "sec-fetch-dest" to "video", + // "sec-fetch-user" to "?1", + // "sec-ch-ua-mobile" to "?0", + // ) + if (resumeAt > 0) mapOf("Range" to "bytes=${resumeAt}-") else emptyMap() + // ), + // referer = link.referer, + // verify = false + //) + + // init variables + //val contentLength = request.size ?: 0 + //metadata.totalBytes = contentLength + resumeAt + //// save + //metadata.setDownloadFileInfoTemplate( + // DownloadedFileInfo( + // totalBytes = metadata.approxTotalBytes, + // relativePath = relativePath ?: "", + // displayName = displayName, + // basePath = basePath + // ) + //) + //// total length is less than 5mb, that is too short and something has gone wrong + //if (extension == "mp4" && metadata.approxTotalBytes < 5000000) return@withContext ERROR_TOO_SMALL_CONNECTION + //// read the buffer into the filestream, this is equivalent of transferTo + //requestStream = request.body.byteStream() + //metadata.type = DownloadType.IsDownloading + //val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + //var read: Int + //while (requestStream.read(buffer, 0, DEFAULT_BUFFER_SIZE).also { read = it } >= 0) { + // fileStream.write(buffer, 0, read) + // // wait until not paused + // while (metadata.type == DownloadType.IsPaused) delay(100) + // // if stopped then break to delete + // if (metadata.type == DownloadType.IsStopped) break + // metadata.addBytes(read.toLong()) + //} + + + if (metadata.type == DownloadType.IsFailed) { + return@withContext ERROR_CONNECTION_ERROR } if (metadata.type == DownloadType.IsStopped) { @@ -1003,11 +1342,12 @@ object VideoDownloadManager { // note that when failing we don't want to delete the file, // only user interaction has that power + metadata.removeStopListener() metadata.type = DownloadType.IsFailed return@withContext ERROR_CONNECTION_ERROR } finally { fileStream?.closeQuietly() - requestStream?.closeQuietly() + //requestStream?.closeQuietly() metadata.close() } } @@ -1388,20 +1728,28 @@ object VideoDownloadManager { } return suspendSafeApiCall { - downloadThing(context, link, name, folder, "mp4", tryResume, ep.id) { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback - ) - } - } + downloadThing( + context, + link, + name, + folder, + "mp4", + tryResume, + ep.id, + createNotificationCallback = { meta -> + main { + createNotification( + context, + source, + link.name, + ep, + meta.type, + meta.bytesDownloaded, + meta.bytesTotal, + notificationCallback + ) + } + }) }.also { extractorJob.cancel() } ?: ERROR_UNKNOWN } From 61ca0a56bea81ef7bd18f943006df56ff4b8e707 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 19 Aug 2023 21:38:29 +0200 Subject: [PATCH 17/39] Translations update from Hosted Weblate (#546) Co-authored-by: Alexandru Co-authored-by: Alexthegib Co-authored-by: Alexthegib Co-authored-by: Amir Co-authored-by: Astrid Co-authored-by: Carlos Luiz Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com> Co-authored-by: Danilo Co-authored-by: Eryk Michalak Co-authored-by: Ettore Atalan Co-authored-by: Fjuro Co-authored-by: Htet Oo Hlaing Co-authored-by: Imprevisible Co-authored-by: Jan Haider Co-authored-by: Jimuel Mallari Co-authored-by: Massimo Pissarello Co-authored-by: Milo Ivir Co-authored-by: PiterDev Co-authored-by: Rex_sa Co-authored-by: Reza Almanda Co-authored-by: Rudy Tantono Co-authored-by: Skrripy Co-authored-by: dabao1955 Co-authored-by: gallegonovato Co-authored-by: george kitsoukakis Co-authored-by: infoekcz Co-authored-by: tuan041 --- app/src/main/res/values-ar/strings.xml | 3 +- app/src/main/res/values-bp/strings.xml | 79 ++- app/src/main/res/values-cs/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 3 +- app/src/main/res/values-fil/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 24 +- app/src/main/res/values-in/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-my/strings.xml | 556 ++++++++++++++++++ app/src/main/res/values-nl/strings.xml | 5 +- app/src/main/res/values-pl/strings.xml | 5 +- app/src/main/res/values-pt/strings.xml | 3 +- app/src/main/res/values-qt/strings.xml | 3 +- app/src/main/res/values-uk/strings.xml | 37 +- app/src/main/res/values-vi/strings.xml | 15 +- fastlane/metadata/android/pt/changelogs/2.txt | 1 + .../metadata/android/pt/full_description.txt | 10 + .../metadata/android/pt/short_description.txt | 1 + fastlane/metadata/android/pt/title.txt | 1 + fastlane/metadata/android/vi/changelogs/2.txt | 1 + .../metadata/android/vi/full_description.txt | 10 + .../metadata/android/vi/short_description.txt | 1 + fastlane/metadata/android/vi/title.txt | 1 + 23 files changed, 734 insertions(+), 37 deletions(-) create mode 100644 app/src/main/res/values-fil/strings.xml create mode 100644 app/src/main/res/values-my/strings.xml create mode 100644 fastlane/metadata/android/pt/changelogs/2.txt create mode 100644 fastlane/metadata/android/pt/full_description.txt create mode 100644 fastlane/metadata/android/pt/short_description.txt create mode 100644 fastlane/metadata/android/pt/title.txt create mode 100644 fastlane/metadata/android/vi/changelogs/2.txt create mode 100644 fastlane/metadata/android/vi/full_description.txt create mode 100644 fastlane/metadata/android/vi/short_description.txt create mode 100644 fastlane/metadata/android/vi/title.txt diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 9b440e6f..987211a5 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -584,4 +584,5 @@ @string/default_subtitles لا توجد اضافة في المستودع المستودع لم يتم العثور عليه، تحقق من العنوان اوجرب شبكة افتراضية خاصة(vpn) - + لقد صوتت بالفعل + \ No newline at end of file diff --git a/app/src/main/res/values-bp/strings.xml b/app/src/main/res/values-bp/strings.xml index 38424e56..2a3bdb27 100644 --- a/app/src/main/res/values-bp/strings.xml +++ b/app/src/main/res/values-bp/strings.xml @@ -10,7 +10,7 @@ %dm Poster - @string/result_poster_img_des + Pôster Episode Poster Main Poster Next Random @@ -66,7 +66,7 @@ Erro Carregando Links Armazenamento Interno Dub - Leg + Sub Deletar Arquivo Assistir Arquivo Retomar Download @@ -257,7 +257,7 @@ Não mostrar de novo Pular essa Atualização Atualizar - Qualidade preferida + Qualidade preferida de reprodução (Wi-fi) Máximo de caracteres do título de vídeos Resolução do player de vídeo Tamanho do buffer do vídeo @@ -428,4 +428,75 @@ Começa o próximo episódio quando o atual termina Ativar NSFW em fornecedores compatíveis Fornecedores - + Reverter + Ações + votou com sucesso + Baixando atualização do aplicativo… + Referencias + Atualizações do App + Tocar com CloudStream + Automaticamente instale todos os plugins não instalados dos repositórios adicionados. + Reproduzir Trailer + Navegador + Copia de Segurança + A Barra de Progresso pode ser usada quando o player estiver oculto + Inscrever + Essa lista está vazia. Tente mudar para outra. + Reproduzir Livestream + Log do Teste + Baixa plugins automaticamente + Selecione o modo para filtrar os plugins baixados + Teste falhou + A Barra de Progresso pode ser usada quando o player estiver visível + Organizar + Sim + Você tem certeza que deseja sair\? + Instalando atualização do aplicativo… + Editar + Perfis + Exibindo Player - procure na Barra de Progresso + remover dos assitidos + Extensões + Alfabética(A => Z) + Abrir com + Selecionar Biblioteca + Passou + Sua biblioteca está vazia :0 +\nEntre numa conta de biblioteca ou adicione Midias para sua biblioteca local. + Qualidade preferida de reprodução (Dados Móveis) + Legado + Biblioteca + Não + Trilhas Sonoras + Votação(Baixa para Alta) + Atualização iniciada + Conteúdo +18 + Ajuda + Processo de configuração de Redo + Não pudemos instalar a nova versão do App + instalador de pacotes + Organizar por + Votação(Alta para Baixa) + Alfabética(Z => A) + Qualidade + Perfil de plano de fundo + Aqui você pode alterar como as fontes são ordenadas. Se um vídeo tiver uma prioridade mais alta, aparecerá mais alto na seleção da fonte. A soma da prioridade da fonte e da prioridade da qualidade é a prioridade do vídeo. +\n +\nFonte A: 3 +\nQualidade B: 7 +\nTerá uma prioridade de vídeo combinada de 10. +\n +\nNOTA: Se a soma for 10 ou mais, o Player pulará automaticamente o carregamento quando o link for carregado! + Arquivo de modo de segurança encontrado! +\nNão carregar nenhuma extensão na inicialização até que o arquivo seja removido. + Inscrevel em %d + Episódio %d Lançado + Selecionar padrão + Disinscrevel em %d + Alguns aparelhos não possuem suporte para este pacote de instalação. Tente a opção legada se a atualização não instalar. + Dados móveis + Perfil %d + Atualizando shows inscritos + Player oculto - Procure na barra de progresso + Conteúdo +18 + \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f304199e..165dbbb4 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -576,4 +576,5 @@ V repozitáři nebyly nalezeny žádné doplňky Repozitář nenalezen, zkontrolujte adresu URL a zkuste použít VPN @string/default_subtitles - + Již jste hlasovali + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 42e07c90..6326211e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -552,4 +552,5 @@ @string/default_subtitles No se encontraron complementos en el repositorio Repositorio no encontrado, comprueba la URL y prueba la VPN - + Ya has votado + \ No newline at end of file diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml new file mode 100644 index 00000000..a6b3daec --- /dev/null +++ b/app/src/main/res/values-fil/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 36c1cf1f..4cc56207 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -530,4 +530,26 @@ Joueur représenté - Montant de la recherche Joueur caché - Montant de la recherche Impossible d\'accéder à GitHub. Activation du proxy jsDelivr… - + Vous avez déjà voté + Désactivé + Ici, vous pouvez modifier la façon dont les sources sont ordonnées. Si une vidéo a une priorité plus élevée, elle apparaîtra plus haut dans la sélection de la source. La somme de la priorité source et de la priorité qualité est la priorité vidéo. +\n +\nSource A : 3 +\nQualité B : 7 +\nLa priorité vidéo combinée sera de 10. +\n +\nREMARQUE : Si la somme est de 10 ou plus, le joueur sautera automatiquement le chargement lorsque ce lien est chargé ! + Aucun plugin trouvé dans ce dossier + Dossier non trouvé, vérifiez l\'url et essayé un VPN + Données mobiles + Définir par défaut + Utiliser + Modifier + Profils + Aide + Profil %d + Wi-Fi + Qualités + L\'interface utilisateur n\'a pas pu être créée correctement. Il s\'agit d\'un bogue majeur qui doit être signalé immédiatement %s + Sélectionnez le mode pour filtrer le téléchargement des plugins + \ No newline at end of file diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 2bd86090..87a01ff9 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -574,4 +574,6 @@ Pilih mode untuk memfilter unduhan plugin Tidak ada plugin yang ditemukan di repositori Repositori tidak ditemukan, periksa URL dan coba VPN - + Kamu sudah voting + \@string/default_subtitles + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dddc57c4..7cca78ca 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -574,4 +574,5 @@ Seleziona la modalità per filtrare il download dei plugin @string/default_subtitles Disabilita - + Hai già votato + \ No newline at end of file diff --git a/app/src/main/res/values-my/strings.xml b/app/src/main/res/values-my/strings.xml new file mode 100644 index 00000000..cde13ba5 --- /dev/null +++ b/app/src/main/res/values-my/strings.xml @@ -0,0 +1,556 @@ + + + သရုပ်ဆောင်များ: %s + %dရက် %ddနာရီ %ddမိနစ် + %ddနာရီ %ddမိနစ် + %ddမိနစ် + ပိုစတာ + အပိုင်း ပိုစတာ + မိန်း ပိုစတာ + နောက် ကျပန်း + နောက်သို့ + နောက်ခံပုံရိပ်ကို အကြိုကြည့်ရန် + အဆင့်: %.1f + အပ်ဒိတ်အသစ်! +\n%s -> %s + စစ်ထုတ်မှု + %d မိနစ် + CloudStream + CloudStream ဖြင့်ကြည့်ရန် + ပင်မ + ရှာရန် + ရှာရန်… + ရှာရန် %s… + အချက်အလက်မရှိပါ + အခြားရွေးစရာများ + နောက်အပိုင်း + ကဏ္ဍများ + မျှဝေမည် + ဘရောက်ဇာတွင်ဖွင့်ရန် + ဘရောက်ဇာ + မစောင့်တော့ပါ + ကြည့်နေသည် + ကြည့်ပြီး + ကြည့်ခြင်းရပ်ထားသော + ဘာမျှ + လင့်များချိတ်ဆက်ရာတွင်အချို့အယွင်း + ဖုန်း သိုလှောင်ရုံ + စာမှတ်များ စစ်ထုတ်မှု + စာမှတ်များ + ဖယ်ရှားရန် + ကြည့်ရှုမှုအခြေအနေသတ်မှတ်ခြင်း + မိတ္တူကူးရန် + ပိတ်ရန် + ရှင်းလင်းရန် + သိမ်းဆည်းရန် + ကြည့်ရှုမှုအရှိန် + နောက်ခံ အရောင် + ဝင်းဒိုး အရောင် + အစွန်းနားပုံစံ + စာတန်းထိုး အမြင့် + ဖောင့် + ဖောင့် အရွယ်အစား + အမျိုးအစားများအသုံးပြု၍ရှာရန် + %d အက်ပ်ဖန်တီးသူတွေကိုကျေးဇူးတင်ကြောင်းပို့မည် + အလိုအလျောက် ဘာသာစကားရွေးချယ်ခြင်း + ဒေါင်းလုဒ် လုပ်ထားသော ဘာသာစကားများ + မူရင်းပုံစံအတိုင်းပြန်ထားရန်ဖိထားပါ + ဆက်လက်ကြည့်ရှုမည် + ဖယ်ရှားရန် + ပိုမို၍ + \@string/home_play + ဒီဟာကတောရပ်တစ်ခုပါ ဗီပီအန်တစ်ခုသုံးဖို့အကြံပြုပါတယ် + ဖော်ပြချက် + ဇာတ်လမ်းသွား မတွေ့ပါ + ဖော်ပြချက် မရိှပါ + Logcat ပြရန် 🐈 + Log + ရုပ်ပုံထပ် + ကြည့်ရှုမှု စခရင်အရွယ်အစားချိန်ညိှမှု + စာတန်းထိုးများ + ကြည့်ရှုမှုစာတန်းထိုးပြုပြင်စရာများ + Eigengravy လုပ်ဆောင်မှု + ရစ်ရန်ဘယ်ညာဆွဲပါ + သင်ရောက်နေတဲ့နေရာပြောင်းရန်ဘယ်ညာဆွဲပါ + ပြုပြင်စရာရိှပါက ပွတ်ဆွဲပါ + နောက်အပိုင်းကို အလိုအလျောက် ဖွင့်ပါ + ကျော်ရန်နှစ်ချက်နှိပ်ပါ + ရပ်ရန်နှစ်ချက်နှိပ်ပါ + ကျော်လိုသောပမာဏ (စက္ကန့်များ) + ရှေ့သို့ကျော်ရန် သို့ နောက်သို့ရစ်ရန် ဘယ် သို့ ညာ ပေါ်မှာနှစ်ချက်နှိပ်ပါ + ရပ်ရန် အလယ်တွင်နှစ်ချက်နှိပ်ပါ + ဖုန်းအလင်းအမှောင်အတိုင်းသုံးမည် + အက်ပ်ကြည့်ရှုမှုထဲမှာ ဖုန်းအလင်းအမှောင်အတိုင်းသုံးမည် + ကြည့်ရှုမှုတိုးတက်ခြင်းကိုအပ်ဒိတ်လုပ်ပါ + အရန်သိမ်းဖိုင်မှပြန်သိုလှောင်မည် + အရန်သိမ်းမည် + အရန်သိမ်းဖိုင်များရယူပြီး + အရန်သိမ်းဖိုင်မှပြန်သိုလှောငးခြင်မအောင်မြင်ပါ %s + သိုလှောင်ပြီး + သိုလှောင်ရုံခွင့်ပြုချက်မရိှပါ။ပြန်ကြိုးစားပါ။ + အရန်သိမ်းနေစဥ်အချို့အယွင်း %s + လိုက်ဘရီ + အပ်ဒိတ်များနှင့်အရန်သိမ်းဆည်းမှု + နက်နက်ရှိုင်းရှိုင်းရှာခြင်း + သင့်ကိုဝန်ဆောင်မှုပေးသူအလိုက်ရှာဖွေမှုရလဒ်များပေးမည် + ချို့ယွင်းမှုအကြီးစားဖြစ်မှသာဒေတာများပေးပို့ပါ + anime များအတွက်ဖြည့်စွက်အပိုင်းကိုပြရန် + ထွေလာများကိုပြရန် + Kitsu မှ ပိုစတာများကိုပြရန် + အလိုအလျောက် ဖြည့်စွက်လုပ်ဆောင်ချက်များကိုအပ်ဒိတ်တင်ခြင်း + အပိုလုပ်ဆောင်ချက်များကိုစစ်ထုတ်ရန်မုဒ်ရွေးပါ + အက်ပ်အပ်ဒိတ်များပြရန် + အစီအစဥ်ချခြင်းကိုပြန်စမည် + အက်ပ်ထည့်သွင်းခြင်း + အချို့ဖုန်းတွေက အက်ပ်ထည့်သွင်းခြင်းလုပ်ဆောင်ချက်အသစ်ကို မပံ့ပိုးပါဘူး။အကယ်၍အလုပ်မဖြစ်ပါကသမားရိုးကျနည်းလမ်းကိုအသုံးပြုပါ။ + Github + ဤဝန်ဆောင်မှုပေးသူသည် Chromecast ကိုမပံ့ပိုးပါ + လင့်များမတွေ့ပါ + ကလစ်ဘုတ်သို့မိတ္တူကူးပြီး + အပိုင်းကြည့်မည် + အတွဲ + အတွဲမရှိပါ + အပိုင်း + အပိုင်းများ + %d-%d + ဒါကအပြီးဖျက်ခြင်းဖြစ်ပါသည် %s +\nသင်သေချာပါသလား။ + %dမိနစ် +\nကျန်ရိှသည် + ထုတ်လွှင့်နေဆဲ + ထုတ်လွှင့်မှုပြီးဆုံး + အခြေအနေ + ခုနစ် + အဆင့်သတ်မှတ်ချက် + ကြာချိန် + ဆိုဒ် + အကျဥ်းချုပ် + နောက်အစီအစဥ် + စာတန်းထိုးမထည့် + ပုံသေ + \@string/default_subtitles + ကျန်ရှိသော + အက်ပ် + ရုပ်ရှင်များ + ဇာတ်လမ်းတွဲများ + ကာတွန်းများ + Anime + ေတာရပ်များ + မှတ်တမ်းရုပ်ရှင်များ + OVA + အာရှ ဒရာမာများ + တိုက်ရိုက်ထုတ်လွှင်မှုများ + အပြာဗီဒီယိုများ + အခြား + ရုပ်ရှင် + ဇာတ်လမ်းတွဲ + OVA + တောရပ် + မှတ်တမ်းရုပ်ရှင် + အာရှ ဒရာမာ + တိုက်ရိုက်ထုတ်လွှင့်မှု + အပြာဗီဒီယို + ဗီဒီယို + ရင်းမြစ်အချို့အယွင်း + အဝေးထိန်းချုပ်မှုအချို့အယွင်း + တင်ဆက်သူ အချို့အယွင်း + မျှော်လင့်မထားသော အချို့အယွင်း + Chromecast အပိုင်း + Chromecast ဖန်သားပြင် + အက်ပ်တွင်းဖွင့် + ဖွင့်ရန် %s + ဘရောက်ဇာထဲမှာ ဖွင့်ရန် + လင့်ကူးယူရန် + အလိုအလျောက်ဒေါင်းလုဒ် + လင့်များကို ပြန်စစ်ရန် + အရည်အသွေး အမှတ်အသား + နောက်ခံအသံ အမှတ်အသား + စာတန်း အမှတ်အသား + ခေါင်းစဥ် + အပ်ဒိတ်မရှိပါ + အပ်ဒိတ်စစ်ရန် + လော့ခ်ခတ်ရန် + ရင်းမြစ် + OPကိုကျော်ရန် + ဒီအပ်ဒိတ်ကိုကျော်ပါ + အပ်ဒိတ် + ခေါင်းစဥ်အတွက်စာလုံးရေပြည့်ခြင်း + ကြည့်ရှုမှု အရည်အသွေး + ဗီဒီယိုရှေ့ပြေးသိမ်းဆည်းမှုပမာဏ + ကြည့်ရှုမှုဘားမြင်တွေ့ရချိန်တွင်ပြသသောပမာဏ + ဝှက်ထားသောကြည့်ရှုပြီးသောပမာဏ + ဝှက်ထားသည့်အခါ အသုံးပြုသည့် ရှာဖွေမှုပမာဏ + Android TV ကဲ့သို့သော မမ်မိုရီနည်းသော စက်ပစ္စည်းများတွင် သတ်မှတ်နှုန်း အလွန်မြင့်မားပါက ပျက်စီးမှုများ ဖြစ်စေသည်။ + DNS over HTTPS + raw.githubusercontent.com ပရောက်စီ + GitHub သို့ မရောက်ရှိနိုင်ပါ။ jsDelivr ပရောက်စီကို ဖွင့်နေသည်… + ကလုန်း ဆိုဒ် + ဆိုဒ်ကိုဖယ်ရှားရန် + မတူညီသော URL တစ်ခုဖြင့် ရှိပြီးသား ဝဘ်ဆိုက်တစ်ခု၏ ပုံတူတစ်ခုကို ထည့်ပါ + ဒေါင်းလုဒ်လမ်းကြောင်း + NGINX ဆာဗာ URL + Dubbed/Subbed Anime ကိုပြသပါ + မျက်နှာပြင်နှင့် အံကိုက် + ဆန့်သည် + ချဲ့သည် + ရှင်းလင်းချက် + ISP ရှောင်လွှဲမှုများ + လင့်များ + အက်ပ်အပ်ဒိတ်များ + Extensions + ဆောင်ရွက်ချက်များ + Cache + Android တီဗွီ + စာတန်းထိုးများ + ပုံသေများ + ပုံပန်းသဏ္ဌာန် + အထွေထွေ + ပင်မစာမျက်နှာမှာကျပန်းခလုတ်ကိုပြပါ + %s အပိုင်း %d + အပိုင်း %d ထုတ်လွှင့်ပြသမည် + ပိုစတာ + ပံ့ပိုးပေးသောဝန်ဆောင်မှုပြောင်းရန် + အရှိန် (%.2fx) + ဒေါင်းလုဒ်များ + ပြင်ဆင်ရန် + ခဏစောင့်ပါ… + ကြည့်ဆဲ + ကြည့်ရန် + ပြန်ကြည့်နေသည် + စာတန်းထိုး + ရုပ်ရှင်ကြည့်မည် + ထွေလာ ကြည့်မည် + လိုက်ခ် ကြည့်မည် + တောရပ် ကြည့်မည် + ရင်းမြစ်များ + ချိတ်ဆက်မှုပြန်ကြိုးစား… + နောက်သို့ + အပိုင်း ကြည့်မည် + ဒေါင်းလုဒ် + ဒေါင်းလုဒ် လုပ်ပြီး + ဒေါင်းလုဒ် လုပ်နေသည် + ဒေါင်းလုဒ် ရပ်ထား + ဒေါင်းလုဒ်စတင် + ဒေါင်းလုဒ် မအောင်မြင် + ဒေါင်းလုဒ် ပယ်ဖျက်ပြီး + ဒေါင်းလုဒ်ပြီးစီး + အပ်ဒိတ်စတင် + တိုက်ရိုက်ကြည့်မည် + နောက်ခံအသံ + ရှာဖွေမှုရလဒ်များတွင်ရွေးချယ်ထားသောဗီဒီယိုအရည်အသွေးကိုဝှက်ထားရန် + စာတန်းထိုး + ဖိုင်ဖျက်ရန် + ဖိုင်ကို ဖွင့်ရန် + ဒေါင်းလုဒ် ဆက်လုပ်ရန် + ဒေါင်းလုဒ် ရပ်ရန် + အလိုအလျောက်အက်ပ်ချို့ယွင်းချက်ပေးပို့ခြင်းကိုပိတ်မည် + ပိုမို၍ + ပ့ံပိုးပေးသောဝန်ဆောင်မှုများအသုံးပြု၍ရှာရန် + ဝုက်ရန် + ကျေးဇူးတင်ကြောင်းမပို့ရသေး + ကြည့်မည် + အချက်အလက် + စာတန်းထိုး ဘာသာစကား + ဒီမှာနေရာချခြင်းဖြင့်ဖောင့်များကိုသွင်းပါ %s + အတည်ပြု + ပယ်ဖျက်ရန် + စာတန်းထိုး ပြုပြင်ခြင်း + စာသား အရောင် + အနားကွပ် အရောင် + ဒီပံ့ပိုးမှုကောင်းမွန်စွာအလုပ်လုပ်ရန်ဗီပီအန်တစ်ခုလိုနိုင်ပါတယ် + အသေးစိတ်အချက်အလက်များပြမထားပါ။ဝဘ်ဆိုဒ်ပေါ်မှာမရှိလျှင်ကြည့်ရှု၍မရနိုင်ပါ။ + ဖြည့်စွက်လုပ်ဆောင်ချက်များကို အလိုအလျောက်ဒေါင်းလုဒ်လုပ်ခြင်း + ရီပိုစစ်ထရီများမှမထည့်သွင်းရသေးသောဖြည့်စွက်လုပ်ဆောင်ချက်များအားလုံးကိုထည့်သွင်းပါ။ + ပြန်ကြည့်ခြင်းကိုအသေးစား ကြည့်ရှုမှုတွင်ဆက်ပြပါ + အနက်ရောင်ဘောင်များကို ဖယ်ရှားရန် + အက်ပ်ထဲဝင်လိုက်သည့်နှင့်အက်ပ်အပ်ဒိတ်ကိုစစ်ဆေးပါ။ + Chromecast စာတန်းထိုးများ + Chromecast စာတန်းထိုး ပြုပြင်ရန် + ကြည့်ရှုမှုပုံစံထဲမှာအရိှန်ရွေးစရာတစ်ခုထည့်ရန် + အသံအတိုးအကျယ်နှင့်အလင်းအမှောင်များကိုချိန်ညိှရန် ဘယ် သို့ ညာ ဘက်တွင် အပေါ်အောက်ဆွဲပါ + ယခုကြည့်နေသောအပိုင်းပြီးပါကနောက်အပိုင်းကိုဖွင့်ပါ + သင့်၏အပိုင်းကြည်ရှုမှုရောက်ရှိနေရာကိုအလိုအလျောက်သိမ်းဆည်းပါ + ရှာရန် + အကောင့်များ + အချက်အလက် + ဒေတာများမပို့ရန် + ကြည့်ရှုပြီးသောအချိန်ပမာဏ + Android TV ကဲ့သို့သော သိုလှောင်မှုနေရာနည်းပါးသော စက်ပစ္စည်းများတွင် အလွန်မြင့်မားစွာ သတ်မှတ်ပါက ပြဿနာများ ဖြစ်လာနိုင်သည်။ + ISP ပိတ်ဆို့ခြင်းကို ကျော်လွှားရန်အတွက် အသုံးဝင်သည် + jsDelivr ကို အသုံးပြု၍ GitHub ပိတ်ဆို့ခြင်းကို ကျော်ဖြတ်သည်။ အပ်ဒိတ်များကို ရက်အနည်းငယ်ကြာအောင် နှောင့်နှေးစေနိုင်သည်။ + အရန်သိမ်းထားသော + လက်ဟန်များ + ကြည့်ရှုမှုလုပ်ဆောင်ချက်များ + အပြင်အဆင် + လုပ်ဆောင်ချက်များ + ကျပန်းခလုတ် + ရှေ့ပြေးအပ်ဒိတ်များကိုထည့်သွင်းပါ + ပုံမှန်အပ်ဒိတ်များအစား ရှေ့ပြေးအက်ဒိတ်များကိုရှာပါ + တူညီသောအက်ပ်ရေးသားသူများ၏ ဝတ္ထုရှည်များဖတ်နိုင်သည့် အက်ပ် + တူညီသောအက်ပ်ရေးသားသူများ၏ Anime အက်ပ် + Discord ကိုဝင်ရန် + အက်ပ်ရေးသားသူများထံ ကျေးဇူးတင်စာပို့မည် + ပေးခဲ့သောစာအရေအတွက် + အက်ပ်ဘာသာစကား + မူလအခြေအနေများကိုပြန်ထားပါ + စိတ်မကောင်းပါ။အက်ပ်ရပ်တန့်သွားပါတယ်။အမည်မဖော်ထားတဲ့တင်ပြချက်ကို အက်ပ်ရေးသားသူများထံ ပို့မှာဖြစ်ပါတယ် + %s %d%s + အတွဲ + %d %s + အပိုင်း + အပိုင်းများမတွေ့ပါ + ဖိုင်ကိုဖျက်ရန် + ဖျက်ရန် + ရပ်ရန် + စရန် + မအောင်မြင်ပါ + ကျော်ဖြတ်ပြီး + ကြည့်လက်စ + -30 + +30 + အသုံးပြုပြီးသော + ကာတွန်း + Anime + ဒေါင်းလုဒ် အချို့အယွင်း၊သိုလှောင်ရုံခွင့်ပြုချက်တွေကိုစစ်ဆေးပါ + ဒေါင်းလုဒ် ကြေးမုံ + စာတန်းထိုးများကို ဒေါင်းလုဒ်လုပ်ရန် + ပိုစတာပေါ်ရှိ UI အစိတ်အပိုင်းများကို ပြောင်းပါ + ပြန်ညိှ + နောက်ထပ်မပြရန် + ဝိုင်ဖိုင်ဖြင့်ကြည့်စဥ်ဗီဒီယိုအရည်အသွေး + မိုဘိုင်းဒေတာဖြင့်ကြည့်စဥ်ဗီဒီယိုအရည်အသွေး + ဗီဒီယိုရှေ့ပြေးသိမ်းဆည်းမှုအကွာအဝေး + ဗီဒီယိုcacheအများ + ဗီဒီယို cache နှင့် ရုပ်ပုံ cache များကိူရှင်းလင်းရန် + ပံ့ပိုးပေးထားသည့် ဝန်ဆောင်မှုများပေါ်တွင် အပြာဗီဒီယို ကို ဖွင့်ပါ + ဝန်ဆောင်မှုပံ့ပိုးသူဘာသာစကား + အက်ပ်အပြင်အဆင် + ဦးစားပေးမီဒီယာ + အက်ပ် အပြင်အဆင် + ပိုစတာခေါင်းစဉ်တည်နေရာ + ခေါင်းစဉ်ကို ပိုစတာအောက်မှာ ထားပါ + ဖုန်းအပြင်အဆင် + အင်မြူလိတ်တာ အပြင်အဆင် + အဓိကအရောင် + အကြံပြုသည် + ဒီဗွီဒီ + 4K + ထုတ်လွှင့်ရန် လင့်ခ် နှင့်ချိတ်ပါ + ရည်ညွှန်းသည် + ရှေ့သို့ + နောက်သို့ + အပ်ဒိတ်လုပ်ပြီး %d ဖြည့်စွက်များ + ဒေါင်းလုဒ်မလုပ်ရသေး: %d + ဝဘ်ဘရောက်ဇာ + အက်ပ်မတွေ့ပါ + ဘာသာစကားအားလုံး + ကျော်ရန် %s + အစမှပြန်စ + ရောထားသောအဆုံးပိုင်း + ရောထားသောအစပိုင်း + ခရက်ဒစ်များ + အစ + သေချာသည် + သမားရိုးကျ + ထည့်သွင်းသူ + ထွက်ချိန်တွင် အက်ပ်ကို အပ်ဒိတ်လုပ်ပါမည် + CloudStream တွင် မူရင်းအတိုင်း ထည့်သွင်းထားသည့်ဆိုက်များ မရှိပါ။ ရီပိုစစ်ထရီများ မှ ဆိုဒ် များကို ထည့်သွင်းရန်လိုအပ်သည်။ +\n +\nSky UK Limited မှ ဦးနှောက်မဲ့ DMCA ကို ဖယ်ရှားလိုက်ခြင်းကြောင့် 🤮 ကျွန်ုပ်တို့သည် ရီပိုစစ်ထရီဆိုဒ်ကို အက်ပ်တွင် ချိတ်ဆက်၍မရပါ။ +\n +\nကျွန်ုပ်တို့၏ Discord တွင်ပါဝင်ပါ သို့မဟုတ် အွန်လိုင်းတွင်ရှာဖွေပါ။ + အခြားသူများ၏ရီပိုစစ်ထရီများကိုရှာဖွေမည် + အသံများ + အသံဖိုင်များ + ဗီဒီယိုအသံဖိုင်များ + ပြန်စတင်ချိန်မှာအသုံးပြုပါ + ရပ်ရန် + လုံခြုံသောမုဖွင့်ရန် + ဖုန်းတွင်းကြည့်ရှုမှု + ဦးစားပေး ဗီဒီယိုဖွင့်စက် + ပြဿနာဖြစ်စေသည့်အရာကို သင်ရှာဖွေရာတွင် အထောက်အကူဖြစ်စေရန်အတွက် ပျက်စီးမှုတစ်ခုကြောင့် အဆက်များအားလုံးကို ပိတ်ထားသည်။ + အဆင့်သတ်မှတ်ချက်များ: %s + ပျက်စီးမှုအချက်အလက်ကို ကြည့်ပါ + ဖော်ပြချက် + ဗားရှင်း + အခြေအနေ + အရွယ်အစား + ရေးသားသူများ + HLS ဖွင့်စဥ် + ကြည့်ရှုခဲ့သည်များ + အက်ပ်၏ ဗားရှင်းအသစ်ကို ထည့်သွင်း၍မရပါ + အစပိုင်း/အဆုံးပိုင်းအတွက် ကျော်နိုင်သော ပေါ့ပ်အပ်များကို ပြပါ + စာသားအလွန်များသဖြင့်ကလစ်ဘုတ်တွင် သိမ်းဆည်း၍မရပါ။ + ပံ့ပိုးပေးသူများ + အပြင်အဆင် + အလိုအလျောက် + တီဗွီအပြင်အဆင် + သင့်စကားဝှက် + သင့်ယူဇာနိမ်း + သင့်အီးမေးလ် လိပ်စာ + 127.0.0.1 + သင့်ဆိုဒ် + example.com + အရိပ် + ထမြောက်မှု + စာတန်းထိုးများ ထပ်တူပြုရန် + စာတန်းထိုးများ အလွန်စောနေပါက %d ms ဒီဟာကိုသုံးပါ + The quick brown fox jumps over the lazy dog + တင်ပြီး %s + ဖိုင်မှတင်သွင်းပြီး + ရုံရိုက် + အကြည် + အကြည် + UHD + HDR + SDR + Web + WP + SD + ကြည့်ရှုမှု + ပိုစတာပုံရိပ် + စာတန်းထိုးများမှ bloat ကိုဖယ်ရှားပါ + နှစ်သက်ရာ မီဒီယာဘာသာစကားဖြင့် စစ်ထုတ်ပါ + အပိုများ + ဒေတာမမှန်ပါ + URL မမှန်ပါ + အချို့အယွင်း + စာတန်းထိုးများမှ ပိတ်ထားသော စာတန်းများကို ဖယ်ရှားပါ + ထွေလာ + ဖြည့်စွက်များ + ရီပိုစစ်ထရီ ဖြည့်စွက်များအားလုံးကိုဖျက်မည်ဖြစ်သည် + ရီပိုစစ်ထရီ ကိုဖျက်ရန် + ဤရီပိုစစ်ထရီမှ ဖြည့်စွက်များအားလုံးကို ဒေါင်းလုဒ်လုပ်မှာလား\? + %s (ပိတ်ပြီး) + ထောက်ပံ့ထားသော + ဘာသာစကား + အဆက်များကိုအရင်သွင်းပါ + VLC + MPV + ဝဘ်ထဲတွင်ဖွင့်ရန် + အစပိုင်း + အဆုံးပိုင်း + ကြည့်ရှုခဲ့သည်များကိုရှင်းရန် + ကြည့်ပြီးသည်မှဖယ်ရှားရန် + သင်ထွက်ရန်သေချာပြီလား + မသေချာပါ + အက်ပ်အပ်ဒိတ်အားဒေါင်းလုဒ်လုပ်နေသည်… + အက်ပ်အပ်ဒိတ်အားသွင်းနေသည်… + အပ်ဒိတ်ဖြစ်မှု (အသစ် မှ အဟောင်း) + သင့်လိုက်ဘရီသည် ဗလာဖြစ်နေသည် :( +\nအကောင့်ဝင်ပါ သို့မဟုတ် သင့်ဖုန်းလိုက်ဘရီတွင် ကြည့်စရာများထည့်ပါ။ + သုံးရန် + တည်းဖြတ်ရန် + အရည်အသွေးများ + စာတန်းထိုး ကုဒ်လုပ်ခြင်း + ပံ့ပိုးပေးသူ စစ်ဆေးမှု + %s %s + အကောင့်ဝင်မည် + အကောင့်ပြောင်းမည် + ချိန်ညိှခြင်း + /%d + အင်တာနက်မှ တင်သွင်းမည် + နောက်ခံ + အကောင့်မဝင်ရောက်နိုင်ပါ %s + ဘာမျှ + အနည်းဆုံး + အနားကွပ် + ကျပန်း + ချုံ့ပြီး + 1000 ms + စာတန်းထိုး ကြန့်ကြာမှု + စာတန်းထိုးများအလွန်နောက်ကျနေပါက %d ms ဒီဟာကိုသုံးပါ + စာတန်းထိုး ကြန့်ကြာမှု သတ်မှတ်ထားခြင်းမရှိ + ရုံရိုက် + ရုံရိုက် + TC + အရည်အသွေး + အစီအစဥ်ချခြင်းကိုကျော်မည် + ချို့ယွင်းမှုသတင်းပေးပို့ခြင်း + ဘာတွေကြည့်ချင်လဲ + ပြီးပြီ + အဆက်များ + မသွင်းနိုင်ပါ %s + သင့်စက်ပစ္စည်းနှင့် ကိုက်ညီစေရန် အက်ပ်၏အသွင်အပြင်ကို ပြောင်းလဲပါ + ရီပိုစစ်ထရီထည့်ရန် + အသက်ပြည့်ပြီးသူများသာ + ရီပိုစစ်ထရီအမည် + ရီပိုစစ်ထရီ URL + ဖြည့်စွက်များ ထည့်ပြီး + ဤဘာသာစကားများဖြင့် ဗီဒီယိုများကို ကြည့်ရှုပါ + ဖြည့်စွက်များ ဒေါင်းလုဒ်လုပ်ပြီး + ဖြည့်စွက်များဖျက်ပြီး + ဒေါင်းလုဒ်လုပ်ခြင်း စတင်သည် %d %s… + ဒေါင်းလုဒ်လုပ်ပြီး %d %s + အားလုံး %s ဒေါင်းလုဒ်လုပ်ပြီးသား + ရီပိုစစ်ထရီထဲတွင်ဖြည့်စွက်များမတွေ့ပါ + ရီပိုစစ်ထရီမတွေ့ပါ၊URLကိုပြန်စစ်ပြီးဗီပီအန်ဖြင့်ကြိုးစားကြည့်ပါ + အသုတ်လိုက် ဒေါင်းလုဒ် + ဖြည့်စွက် + သင်အသုံးပြုလိုသောဆိုက်များစာရင်းကို ဒေါင်းလုဒ်လုပ်ပါ + ဒေါင်းလုဒ်လုပ်ပြီး: %d + ပိတ်ပြီး: %d + ဘာသာစကားကုဒ် (en) + အကောင့် + အကောင့်ထွက်မည် + အကောင့်ထည့်မည် + အကောင့်ဖွင့်မည် + စောင့်ကြည့်ခြင်းထည့်မည် + ထည့်ပြီး %s + အဆင့်သတ်မှတ်ထားပြီး + %d / 10 + /\?\? + %s ချိတ်ဆက်ပြီး + ပိတ်ပါ + ပုံမှန် + အားလုံး + အပြည့် + ဖိုင်ဒေါင်းလုဒ်လုပ်ပြီး + အဓိက + ထောက်ပံ့သည် + ရင်းမြစ် + မကြာမီလာမည်… + TS + Blu-ray + အရည်အသွေးနှင့်ခေါင်းစဥ် + ခေါင်းစဥ် + အိုင်ဒီမမှန်ပါ + အများမြင်နိုင်သော + စာတန်းထိုးအားလုံးကို စာလုံးအကြီးပြောင်းပါ + ပြန်စတင်မည် + ကြည့်ပြီးအဖြစ်မှတ်ရန် + အစီအစဥ်ချမှု + အစီအစဥ် + အဆင့်သတ်မှတ်ချက် (အမြင့်ဆုံးမှအနိမ့်ဆုံးသို့) + အဆင့်သတ်မှတ်ချက် (အနိမ့်ဆုံး မှ အမြင့်ဆုံးသို့) + အပ်ဒိတ်ဖြစ်မှု (အဟောင် မှ အသစ်) + အက္ခရာစဥ်လိုက် (A မှ Z) + အက္ခရာစဥ်လိုက် (Z မှ A) + ပြောင်းပြန် + စာရင်းသွင်းထားသောရှိုးများကိုအပ်ဒိတ်လုပ်နေသည် + စာရင်းသွင်းပြီး + စာရင်းသွင်းပြီး %s + စာရင်းသွင်းမှုပယ်ဖျက်ပြီး %s + ဤစာရင်းသည် ဗလာဖြစ်နေသည်။ အခြားတစ်ခုသို့ ပြောင်းကြည့်ပါ။ + Safe mode ဖိုင်ကို တွေ့ရှိခဲ့သည်။ +\nဖိုင်ကိုမဖယ်ရှားမချင်း စတင်ဖွင့်စတွင် မည်သည့် extension များကိုမျှ မတင်ပါ။ + အပိုင်းသစ် %d ထွက်ပြီ + ပရိုဖိုင် %d + ဝိုင်ဖိုင် + မိုဘိုင်းဒေတာ + ပုံသေထားရန် + ပရိုဖိုင်များ + အကူအညီ + ဤနေရာတွင် သင်သည် အရင်းအမြစ်များကို မည်ကဲ့သို့ အစီအစဥ်ချမည်ကို ပြောင်းလဲနိုင်သည်။ ဗီဒီယိုတစ်ခုတွင် ပိုမိုဦးစားပေးပါက ရင်းမြစ်ရွေးချယ်မှုတွင် ပိုမိုမြင့်မားလာမည်ဖြစ်သည်။ အရင်းအမြစ် ဦးစားပေးနှင့် အရည်အသွေး ဦးစားပေး၏ ပေါင်းစုသည် ဗီဒီယို ဦးစားပေးဖြစ်သည်။ +\n +\nအရင်းအမြစ် A: 3 +\nအရည်အသွေး B: 7 +\nပေါင်းစပ်ဗီဒီယို ဦးစားပေး 10 ခု ရှိပါမည်။ +\n +\nမှတ်ချက်- ပေါင်းလဒ်သည် 10 သို့မဟုတ် ထို့ထက်ပိုပါက ထိုလင့်ခ်ကို တင်သည့်အခါ ဗီဒီယိုဖွင့်စက်သည် အလိုအလျောက် ဒေါင်းလုဒ်ကို ကျော်သွားမည်ဖြစ်သည် + ပရိုဖိုင်နောက်ခံ + UI ကို မှန်ကန်စွာ ဖန်တီး၍မရပါ၊ ၎င်းသည် အဓိက ချို့ယွင်းချက်တစ်ခုဖြစ်ပြီး ချက်ချင်းသတင်းပို့သင့်သည်။ %s + သင်နဂိုတည်းကသတ်မှတ်ပြီး + လိုက်ဘရီရွေးချယ်ရန် + ဖြင့်ဖွင့်မည် + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 5f60ac14..c33bf107 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -573,5 +573,6 @@ Selecteer een modus om het downloaden van plug-ins te filteren Uitzetten De gebruikersinterface kon niet correct worden gemaakt, dit is een ERNSTIG PROBLEEM en moet onmiddellijk gerapporteerd worden %s - @string/default_subtitles - + \@string/default_subtitles + Je hebt al gestemd + \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6db36065..38c56f0d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -553,4 +553,7 @@ Wybierz tryb filtrowania pobieranych rozszerzeń Wyłączać @string/default_subtitles - + Nie znaleziono żadnych wtyczek w repozytorium + Już oddano głos + Nie znaleziono tego repozytorium, sprawdź adres URL lub spróbuj połączyć się przez VPN + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b2504e84..75d1ddbc 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -552,4 +552,5 @@ Desativar Não foram encontrados plugins no repositório Repositório não encontrado, verifique o URL e tente a VPN - + Você já votou + \ No newline at end of file diff --git a/app/src/main/res/values-qt/strings.xml b/app/src/main/res/values-qt/strings.xml index f763d795..cce4e7d3 100644 --- a/app/src/main/res/values-qt/strings.xml +++ b/app/src/main/res/values-qt/strings.xml @@ -248,4 +248,5 @@ aoaaaaaoooghhh oooooh uuaagh @string/home_play - + oouuhhh ahhooo-ahah + \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2c5d4197..14b2334f 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -80,14 +80,14 @@ Сюжет не знайдено Опис не знайдено Показати Logcat 🐈 - Продовження відтворення в мініатюрному плеєрі поверх інших застосунків + Продовжує відтворення в мініатюрному плеєрі поверх інших застосунків Прибирає чорні рамки Субтитри Субтитри Chromecast Налаштування субтитрів Chromecast Режим Eigengravy Проведіть пальцем, щоб змінити налаштування - Проведіть пальцем вгору або вниз, ліворуч або праворуч, щоб змінити яскравість чи гучність + Проведіть вгору або вниз з лівого або правого боку, щоб змінити яскравість чи гучність Відтворювати наступний епізод після закінчення поточного Головна CloudStream @@ -121,8 +121,8 @@ Колір тексту Колір контуру Автовідтворення наступного епізоду - Проведіть пальцем з боку в бік, щоб керувати своїм положенням у відео - %d Бананів для розробників + Проведіть з боку в бік, щоб керувати своїм положенням у відео + %d бананів для розробників Кнопка зміни розміру плеєра @string/home_play Для коректної роботи цього постачальника може знадобитися VPN @@ -133,7 +133,7 @@ Проведіть пальцем, щоб перемотати Двічі торкніться, щоб перемотати Двічі торкніться для паузи - Крок перемотки (Секунди) + Крок перемотки (секунди) Натисніть двічі посередині, щоб призупинити відтворення відео Використовувати яскравість системи Оновити прогрес перегляду @@ -150,8 +150,8 @@ Надає результати пошуку, розділені за постачальниками Надсилає дані лише про збої Не надсилає даних - Показати заповнюючий епізод для аніме - Показати трейлери + Показувати філери до аніме + Показувати трейлери Приховати вибрану якість відео в результатах пошуку Автоматичне завантаження плагінів Показувати оновлення застосунку @@ -214,12 +214,12 @@ Завантажити дзеркало Перевірити наявність оновлень Заблокувати - Пропустити OP + Пропускати OP Не показувати знову Оновити Бажана якість перегляду (WiFi) Заголовок - Перемикання елементів інтерфейсу на плакаті + Перемикання елементів інтерфейсу на постері Оновлення не знайдено Двічі торкніться праворуч або ліворуч, щоб перемотати відео вперед або назад Використовуйте системну яскравість у плеєрі замість темної накладки @@ -227,10 +227,10 @@ Торренти Автоматична синхронізація прогресу поточного епізоду Відсутні дозволи на зберігання. Будь ласка, спробуйте ще раз. - Показати постери від Kitsu + Показувати постери від Kitsu Автоматичне оновлення плагінів Автоматично встановлювати всі ще не встановлені плагіни з доданих репозиторіїв. - Автоматично шукати нові оновлення після запуску застосунку. + Автоматично шукає нові оновлення після запуску застосунку. Оновлення до бета-версій Посилання скопійовано в буфер обміну Деякі телефони не підтримують новий інсталятор пакетів. Спробуйте стару версію, якщо оновлення не встановлюються. @@ -354,7 +354,7 @@ DNS через HTTPS Шлях завантаження Додайте клон існуючого сайту, з іншою URL-адресою - Відображати мітку Дубляж/Субтитри в аніме + Відображати мітку Дубляж/Субтитри до аніме Застереження Розширення Дії @@ -382,7 +382,7 @@ Підтримка Фон Blu-ray - Видалити закриті титри з субтитрів + Видаляти закриті титри з субтитрів DVD Недійсні дані Фільтрувати за бажаною мовою медіа @@ -400,7 +400,7 @@ HD TS TC - Видалити роздуття субтитрів + Видаляти роздуття субтитрів Referer Далі Дивіться відео на цих мовах @@ -451,8 +451,8 @@ Вбудований плеєр VLC MPV - Відтворення веб-відео - Веб-браузер + Web Video Cast + Веббраузер Ендінґ Коротке повторення Пропустити %s @@ -462,7 +462,7 @@ Вступ Очистити історію Історія - Показувати спливаючі вікна для опенінґу/ендінґу + Показує спливаюче вікно для пропуску опенінґу/ендінґу Забагато тексту. Не вдалося зберегти в буфер обміну. Позначити як переглянуте Ви впевнені що хочете вийти\? @@ -552,4 +552,5 @@ @string/default_subtitles Репозиторій не знайдено, перевірте URL-адресу та спробуйте VPN Не знайдено жодних плагінів у репозиторії - + Ви вже проголосували + \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 4b394227..f7abd6db 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -267,7 +267,7 @@ Cập nhật Chất lượng xem ưu tiên (WiFi) Kí tự tối đa trên tiêu đề - Độ phân giải trình phát video + Nội dung trình phát video Kích thước bộ nhớ đệm video Thời lượng bộ nhớ đệm Lưu bộ nhớ đệm video trên ổ cứng @@ -380,8 +380,8 @@ Web Ảnh áp phích Trình phát - Độ phân giải và Tiêu đề - Tiêu đề + Độ phân giải và Tên nguồn + Tên nguồn Độ phân giải Id không hợp lệ Lỗi dữ liệu @@ -561,4 +561,11 @@ \n \nLƯU Ý: Nếu tổng là 10 hoặc nhiều hơn, trình phát sẽ tự động bỏ tải khi liên kết đó được tải! Các phẩm chất - + Bạn đã bình chọn + Vô hiệu hoá + Không tìm thấy tiện ích, hãy kiểm tra URL và thử VPN + Không tìm thấy plugin + Không thể khởi tạo UI, đây là một LỖI LỚN và cần được báo cáo ngay lập tức tới %s + Chọn chế độ để lọc plugin tải xuống + \@string/default_subtitles + \ No newline at end of file diff --git a/fastlane/metadata/android/pt/changelogs/2.txt b/fastlane/metadata/android/pt/changelogs/2.txt new file mode 100644 index 00000000..1153e632 --- /dev/null +++ b/fastlane/metadata/android/pt/changelogs/2.txt @@ -0,0 +1 @@ +- Adicionado o registo de alterações! diff --git a/fastlane/metadata/android/pt/full_description.txt b/fastlane/metadata/android/pt/full_description.txt new file mode 100644 index 00000000..48bf36ce --- /dev/null +++ b/fastlane/metadata/android/pt/full_description.txt @@ -0,0 +1,10 @@ +O CloudStream-3 permite-lhe transmitir e descarregar filmes, séries de TV e anime. + +A aplicação é fornecida sem quaisquer anúncios e análises e +suporta vários sites de trailers e filmes, e muito mais, por exemplo + +Marcadores + +Downloads de legendas + +Suporte para Chromecast diff --git a/fastlane/metadata/android/pt/short_description.txt b/fastlane/metadata/android/pt/short_description.txt new file mode 100644 index 00000000..d0392f34 --- /dev/null +++ b/fastlane/metadata/android/pt/short_description.txt @@ -0,0 +1 @@ +Transmita e transfira filmes, séries de TV e anime. diff --git a/fastlane/metadata/android/pt/title.txt b/fastlane/metadata/android/pt/title.txt new file mode 100644 index 00000000..dde89d58 --- /dev/null +++ b/fastlane/metadata/android/pt/title.txt @@ -0,0 +1 @@ +CloudStream diff --git a/fastlane/metadata/android/vi/changelogs/2.txt b/fastlane/metadata/android/vi/changelogs/2.txt new file mode 100644 index 00000000..e03e458e --- /dev/null +++ b/fastlane/metadata/android/vi/changelogs/2.txt @@ -0,0 +1 @@ +- Đã thêm Nhật ký thay đổi! diff --git a/fastlane/metadata/android/vi/full_description.txt b/fastlane/metadata/android/vi/full_description.txt new file mode 100644 index 00000000..90ea7ab7 --- /dev/null +++ b/fastlane/metadata/android/vi/full_description.txt @@ -0,0 +1,10 @@ +CloudStream-3 cho phép bạn xem và tải xuống phim lẻ, phim bộ và anime. + +Ứng dụng không có quảng cáo hay và phân tích nào, +đồng thời hỗ trợ nhiều trang web xem phim, v.v. + +Đánh dấu + +Tải phụ đề + +Hỗ trợ Chromecast diff --git a/fastlane/metadata/android/vi/short_description.txt b/fastlane/metadata/android/vi/short_description.txt new file mode 100644 index 00000000..e4e20bd5 --- /dev/null +++ b/fastlane/metadata/android/vi/short_description.txt @@ -0,0 +1 @@ +Xem và tải xuống phim lẻ, phim bộ và anime. diff --git a/fastlane/metadata/android/vi/title.txt b/fastlane/metadata/android/vi/title.txt new file mode 100644 index 00000000..dde89d58 --- /dev/null +++ b/fastlane/metadata/android/vi/title.txt @@ -0,0 +1 @@ +CloudStream From 6948bf807373d349844701c4e2eb7e3a438179e0 Mon Sep 17 00:00:00 2001 From: "recloudstream[bot]" <111277985+recloudstream[bot]@users.noreply.github.com> Date: Sat, 19 Aug 2023 19:38:45 +0000 Subject: [PATCH 18/39] chore(locales): fix locale issues --- .../lagradost/cloudstream3/ui/settings/SettingsGeneral.kt | 2 ++ app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-bp/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fil/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-in/strings.xml | 4 ++-- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-my/strings.xml | 6 +++--- app/src/main/res/values-nl/strings.xml | 4 ++-- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-qt/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 4 ++-- 16 files changed, 22 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index d76eba1e..2c81ad1f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -69,6 +69,7 @@ val appLanguages = arrayListOf( Triple("", "Esperanto", "eo"), Triple("", "español", "es"), Triple("", "فارسی", "fa"), + Triple("", "fil", "fil"), Triple("", "français", "fr"), Triple("", "galego", "gl"), Triple("", "हिन्दी", "hi"), @@ -84,6 +85,7 @@ val appLanguages = arrayListOf( Triple("", "македонски", "mk"), Triple("", "മലയാളം", "ml"), Triple("", "bahasa Melayu", "ms"), + Triple("", "ဗမာစာ", "my"), Triple("", "Nederlands", "nl"), Triple("", "norsk nynorsk", "nn"), Triple("", "norsk bokmål", "no"), diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 987211a5..0c11f7e9 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -585,4 +585,4 @@ لا توجد اضافة في المستودع المستودع لم يتم العثور عليه، تحقق من العنوان اوجرب شبكة افتراضية خاصة(vpn) لقد صوتت بالفعل - \ No newline at end of file + diff --git a/app/src/main/res/values-bp/strings.xml b/app/src/main/res/values-bp/strings.xml index 2a3bdb27..425293e4 100644 --- a/app/src/main/res/values-bp/strings.xml +++ b/app/src/main/res/values-bp/strings.xml @@ -499,4 +499,4 @@ Atualizando shows inscritos Player oculto - Procure na barra de progresso Conteúdo +18 - \ No newline at end of file + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 165dbbb4..46bd860d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -577,4 +577,4 @@ Repozitář nenalezen, zkontrolujte adresu URL a zkuste použít VPN @string/default_subtitles Již jste hlasovali - \ No newline at end of file + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6326211e..8e9f9c2c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -553,4 +553,4 @@ No se encontraron complementos en el repositorio Repositorio no encontrado, comprueba la URL y prueba la VPN Ya has votado - \ No newline at end of file + diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index a6b3daec..42eba3cc 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -1,2 +1,2 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4cc56207..208e6140 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -552,4 +552,4 @@ Qualités L\'interface utilisateur n\'a pas pu être créée correctement. Il s\'agit d\'un bogue majeur qui doit être signalé immédiatement %s Sélectionnez le mode pour filtrer le téléchargement des plugins - \ No newline at end of file + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 87a01ff9..d514bcc4 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -575,5 +575,5 @@ Tidak ada plugin yang ditemukan di repositori Repositori tidak ditemukan, periksa URL dan coba VPN Kamu sudah voting - \@string/default_subtitles - \ No newline at end of file + @string/default_subtitles + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7cca78ca..0c34e89a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -575,4 +575,4 @@ @string/default_subtitles Disabilita Hai già votato - \ No newline at end of file + diff --git a/app/src/main/res/values-my/strings.xml b/app/src/main/res/values-my/strings.xml index cde13ba5..0cb44373 100644 --- a/app/src/main/res/values-my/strings.xml +++ b/app/src/main/res/values-my/strings.xml @@ -58,7 +58,7 @@ ဆက်လက်ကြည့်ရှုမည် ဖယ်ရှားရန် ပိုမို၍ - \@string/home_play + @string/home_play ဒီဟာကတောရပ်တစ်ခုပါ ဗီပီအန်တစ်ခုသုံးဖို့အကြံပြုပါတယ် ဖော်ပြချက် ဇာတ်လမ်းသွား မတွေ့ပါ @@ -128,7 +128,7 @@ နောက်အစီအစဥ် စာတန်းထိုးမထည့် ပုံသေ - \@string/default_subtitles + @string/default_subtitles ကျန်ရှိသော အက်ပ် ရုပ်ရှင်များ @@ -553,4 +553,4 @@ သင်နဂိုတည်းကသတ်မှတ်ပြီး လိုက်ဘရီရွေးချယ်ရန် ဖြင့်ဖွင့်မည် - \ No newline at end of file + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index c33bf107..d19726fd 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -573,6 +573,6 @@ Selecteer een modus om het downloaden van plug-ins te filteren Uitzetten De gebruikersinterface kon niet correct worden gemaakt, dit is een ERNSTIG PROBLEEM en moet onmiddellijk gerapporteerd worden %s - \@string/default_subtitles + @string/default_subtitles Je hebt al gestemd - \ No newline at end of file + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 38c56f0d..a170d610 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -556,4 +556,4 @@ Nie znaleziono żadnych wtyczek w repozytorium Już oddano głos Nie znaleziono tego repozytorium, sprawdź adres URL lub spróbuj połączyć się przez VPN - \ No newline at end of file + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 75d1ddbc..908ddb0d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -553,4 +553,4 @@ Não foram encontrados plugins no repositório Repositório não encontrado, verifique o URL e tente a VPN Você já votou - \ No newline at end of file + diff --git a/app/src/main/res/values-qt/strings.xml b/app/src/main/res/values-qt/strings.xml index cce4e7d3..9c68c008 100644 --- a/app/src/main/res/values-qt/strings.xml +++ b/app/src/main/res/values-qt/strings.xml @@ -249,4 +249,4 @@ oooooh uuaagh @string/home_play oouuhhh ahhooo-ahah - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 14b2334f..e0db1c0e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -553,4 +553,4 @@ Репозиторій не знайдено, перевірте URL-адресу та спробуйте VPN Не знайдено жодних плагінів у репозиторії Ви вже проголосували - \ No newline at end of file + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index f7abd6db..217d2791 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -567,5 +567,5 @@ Không tìm thấy plugin Không thể khởi tạo UI, đây là một LỖI LỚN và cần được báo cáo ngay lập tức tới %s Chọn chế độ để lọc plugin tải xuống - \@string/default_subtitles - \ No newline at end of file + @string/default_subtitles + From a3009af4f585c010cd5018037123fefd644f8921 Mon Sep 17 00:00:00 2001 From: self-similarity <137652432+self-similarity@users.noreply.github.com> Date: Sat, 19 Aug 2023 19:48:10 +0000 Subject: [PATCH 19/39] Add Native Crash Handler (#565) * Add NativeCrashHandler * Safer init --- app/CMakeLists.txt | 6 +++ app/build.gradle.kts | 6 +++ app/src/main/cpp/native-lib.cpp | 28 ++++++++++++ .../lagradost/cloudstream3/AcraApplication.kt | 1 + .../cloudstream3/NativeCrashHandler.kt | 44 +++++++++++++++++++ 5 files changed, 85 insertions(+) create mode 100644 app/CMakeLists.txt create mode 100644 app/src/main/cpp/native-lib.cpp create mode 100644 app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 00000000..7f7fd14c --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,6 @@ +# Set this to the minimum version your project supports. +cmake_minimum_required(VERSION 3.18) +project(CrashHandler) +find_library(log-lib log) +add_library(native-lib SHARED src/main/cpp/native-lib.cpp) +target_link_libraries(native-lib ${log-lib}) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 708a2083..d6515289 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,6 +32,12 @@ android { enable = true } + externalNativeBuild { + cmake { + path("CMakeLists.txt") + } + } + signingConfigs { create("prerelease") { if (prereleaseStoreFile != null) { diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp new file mode 100644 index 00000000..f4cb531f --- /dev/null +++ b/app/src/main/cpp/native-lib.cpp @@ -0,0 +1,28 @@ +#include +#include +#include + +#define TAG "CloudStream Crash Handler" +volatile sig_atomic_t gSignalStatus = 0; +void handleNativeCrash(int signal) { + gSignalStatus = signal; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) { + #define REGISTER_SIGNAL(X) signal(X, handleNativeCrash); + REGISTER_SIGNAL(SIGSEGV) + #undef REGISTER_SIGNAL +} + +//extern "C" JNIEXPORT void JNICALL +//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) { +// int *p = nullptr; +// *p = 0; +//} + +extern "C" JNIEXPORT int JNICALL +Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) { + //__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus); + return gSignalStatus; +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index 61d467c4..32702657 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -106,6 +106,7 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : class AcraApplication : Application() { override fun onCreate() { super.onCreate() + NativeCrashHandler.initCrashHandler() Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) { val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) startActivity(Intent.makeRestartActivityTask(intent!!.component)) diff --git a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt new file mode 100644 index 00000000..e5cb2702 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt @@ -0,0 +1,44 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.plugins.PluginManager.checkSafeModeFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +object NativeCrashHandler { + // external fun triggerNativeCrash() + private external fun initNativeCrashHandler() + private external fun getSignalStatus(): Int + + private fun initSignalPolling() = CoroutineScope(Dispatchers.IO).launch { + while (true) { + delay(10_000) + val signal = getSignalStatus() + // Signal is initialized to zero + if (signal == 0) continue + + // Do not crash in safe mode! + if (lastError != null) continue + if (checkSafeModeFile()) continue + + throw RuntimeException("Native crash with code: $signal. Try uninstalling extensions.\n") + } + } + + fun initCrashHandler() { + try { + System.loadLibrary("native-lib") + initNativeCrashHandler() + } catch (t: Throwable) { + // Make debug crash. + if (BuildConfig.DEBUG) throw t + logError(t) + return + } + + initSignalPolling() + } +} \ No newline at end of file From c4852ce440736105d32a18f1774ee65dead98808 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sun, 20 Aug 2023 01:29:50 +0200 Subject: [PATCH 20/39] made HSL downloader even faster --- .../cloudstream3/utils/M3u8Helper.kt | 5 + .../utils/VideoDownloadManager.kt | 215 +++++++++++------- 2 files changed, 132 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt index 5c0b45de..11dfa441 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.app +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec @@ -196,6 +197,8 @@ object M3u8Helper2 { return if(condition()) out else null } catch (e: IllegalArgumentException) { return null + } catch (e : CancellationException) { + return null } catch (t: Throwable) { delay(failDelay) } @@ -213,6 +216,8 @@ object M3u8Helper2 { return resolveLink(index) } catch (e: IllegalArgumentException) { return null + } catch (e : CancellationException) { + return null } catch (t: Throwable) { delay(failDelay) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index d8ef7e85..89094f3f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -43,6 +43,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -1083,6 +1084,39 @@ object VideoDownloadManager { ) } + /** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + private fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } + + private fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + private suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } + @Throws suspend fun downloadThing( context: Context, @@ -1166,8 +1200,9 @@ object VideoDownloadManager { hashMapOf() val jobs = (0 until parallelConnections).map { - launch { + launch(Dispatchers.IO) { + // @downloadexplanation // this may seem a bit complex but it more or less acts as a queue system // imagine we do the downloading [0,3] and it response in the order 0,2,3,1 // file: [_,_,_,_] queue: [_,_,_,_] Initial condition @@ -1177,6 +1212,10 @@ object VideoDownloadManager { // file: [X,X,_,_] queue: [_,_,X,X] + added 1 directly to file // file: [X,X,X,X] queue: [_,_,_,_] write the queue and remove from it + // note that this is a bit more complex compared to hsl as ever segment + // will return several bytearrays, and is therefore chained by the byte + // so every request has a front and back byte instead of an index + // this *requires* that no gap exist due because of resolve val callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) = callback@{ response -> if (!isActive) return@callback @@ -1228,10 +1267,11 @@ object VideoDownloadManager { while (true) { if (!isActive) return@launch fileMutex.withLock { - if (metadata.type == DownloadType.IsStopped) return@launch + if (metadata.type == DownloadType.IsStopped + || metadata.type == DownloadType.IsFailed) return@launch } - // just in case, we never want this to fail due to multithreading + // mutex just in case, we never want this to fail due to multithreading val index = currentMutex.withLock { if (!current.hasNext()) return@launch current.nextInt() @@ -1253,68 +1293,14 @@ object VideoDownloadManager { // fast stop as the jobs may be in a slow request metadata.setOnStop { - jobs.forEach { job -> - try { - job.cancel() - } catch (t: Throwable) { - logError(t) - } - } + jobs.cancel() } - jobs.forEach { it.join() } + jobs.join() // jobs are finished so we don't want to stop them anymore metadata.removeStopListener() - // set up a connection - //val request = app.get( - // link.url.replace(" ", "%20"), - // headers = link.headers.appendAndDontOverride( - // mapOf( - // "Accept-Encoding" to "identity", - // "accept" to "*/*", - // "user-agent" to USER_AGENT, - // "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", - // "sec-fetch-mode" to "navigate", - // "sec-fetch-dest" to "video", - // "sec-fetch-user" to "?1", - // "sec-ch-ua-mobile" to "?0", - // ) + if (resumeAt > 0) mapOf("Range" to "bytes=${resumeAt}-") else emptyMap() - // ), - // referer = link.referer, - // verify = false - //) - - // init variables - //val contentLength = request.size ?: 0 - //metadata.totalBytes = contentLength + resumeAt - //// save - //metadata.setDownloadFileInfoTemplate( - // DownloadedFileInfo( - // totalBytes = metadata.approxTotalBytes, - // relativePath = relativePath ?: "", - // displayName = displayName, - // basePath = basePath - // ) - //) - //// total length is less than 5mb, that is too short and something has gone wrong - //if (extension == "mp4" && metadata.approxTotalBytes < 5000000) return@withContext ERROR_TOO_SMALL_CONNECTION - //// read the buffer into the filestream, this is equivalent of transferTo - //requestStream = request.body.byteStream() - //metadata.type = DownloadType.IsDownloading - //val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - //var read: Int - //while (requestStream.read(buffer, 0, DEFAULT_BUFFER_SIZE).also { read = it } >= 0) { - // fileStream.write(buffer, 0, read) - // // wait until not paused - // while (metadata.type == DownloadType.IsPaused) delay(100) - // // if stopped then break to delete - // if (metadata.type == DownloadType.IsStopped) break - // metadata.addBytes(read.toLong()) - //} - - if (metadata.type == DownloadType.IsFailed) { return@withContext ERROR_CONNECTION_ERROR } @@ -1342,7 +1328,6 @@ object VideoDownloadManager { // note that when failing we don't want to delete the file, // only user interaction has that power - metadata.removeStopListener() metadata.type = DownloadType.IsFailed return@withContext ERROR_CONNECTION_ERROR } finally { @@ -1352,19 +1337,6 @@ object VideoDownloadManager { } } - /** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp - * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) - * */ - private fun Map.appendAndDontOverride(rhs: Map): Map { - val out = this.toMutableMap() - val current = this.keys.map { it.lowercase() } - for ((key, value) in rhs) { - if (current.contains(key.lowercase())) continue - out[key] = value - } - return out - } - @Throws private suspend fun downloadHLS( context: Context, @@ -1429,28 +1401,95 @@ object VideoDownloadManager { metadata.hlsTotal = items.size metadata.type = DownloadType.IsDownloading + + val currentMutex = Mutex() + val current = (0 until items.size).iterator() + + val fileMutex = Mutex() + val pendingData: HashMap = hashMapOf() + + // see @downloadexplanation for explanation of this download strategy, + // this keeps all jobs working at all times, // does several connections in parallel instead of a regular for loop to improve // download speed - (startAt until items.size).chunked(parallelConnections).forEach { subset -> - // wait until not paused - while (metadata.type == DownloadType.IsPaused) delay(100) - // if stopped then break to delete - if (metadata.type == DownloadType.IsStopped) return@forEach + val jobs = (0 until parallelConnections).map { + launch(Dispatchers.IO) { + while (true) { + if (!isActive) return@launch + fileMutex.withLock { + if (metadata.type == DownloadType.IsStopped + || metadata.type == DownloadType.IsFailed + ) return@launch + } - subset.amap { idx -> - idx to items.resolveLinkSafe(idx)?.also { bytes -> - metadata.addSegment(bytes.size.toLong()) + // mutex just in case, we never want this to fail due to multithreading + val index = currentMutex.withLock { + if (!current.hasNext()) return@launch + current.nextInt() + } + + // in case something has gone wrong set to failed if the fail is not caused by + // user cancellation + val bytes = items.resolveLinkSafe(index) ?: run { + fileMutex.withLock { + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } + return@launch + } + + try { + fileMutex.lock() + // user pause + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped || !isActive) return@launch + + // send notification, no matter the actual write order + metadata.addSegment(bytes.size.toLong()) + + // directly write the bytes if you are first + if (metadata.hlsWrittenProgress == index) { + fileStream.write(bytes) + metadata.setWrittenSegment(index) + } else { + // no need to clone as there will be no modification of this bytearray + pendingData[index] = bytes + } + + // write the cached bytes submitted by other threads + while (true) { + fileStream.write( + pendingData.remove(metadata.hlsWrittenProgress) ?: break + ) + metadata.setWrittenSegment(metadata.hlsWrittenProgress) + } + } catch (t : Throwable) { + // this is in case of write fail + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } finally { + fileMutex.unlock() + } } - }.forEach { (idx, bytes) -> - if (bytes == null) { - metadata.type = DownloadType.IsFailed - return@withContext ERROR_CONNECTION_ERROR - } - fileStream.write(bytes) - metadata.setWrittenSegment(idx) } } + // fast stop as the jobs may be in a slow request + metadata.setOnStop { + jobs.cancel() + } + + jobs.join() + + metadata.removeStopListener() + + if (metadata.type == DownloadType.IsFailed) { + return@withContext ERROR_CONNECTION_ERROR + } + if (metadata.type == DownloadType.IsStopped) { // we need to close before delete fileStream.closeQuietly() From 4e28e5f8cc4d811184799cb3aa024665cc0aee3d Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sun, 20 Aug 2023 03:58:31 +0200 Subject: [PATCH 21/39] fixed not downloading the last 20MiB on mp4 downloader + bump + mb/s notification --- app/build.gradle.kts | 2 +- .../utils/VideoDownloadManager.kt | 79 ++++++++++++++----- 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d6515289..50125aa3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,7 +58,7 @@ android { targetSdk = 33 versionCode = 59 - versionName = "4.1.5" + versionName = "4.1.6" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 89094f3f..507abc34 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -30,7 +30,6 @@ import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall @@ -256,7 +255,8 @@ object VideoDownloadManager { total: Long, notificationCallback: (Int, Notification) -> Unit, hlsProgress: Long? = null, - hlsTotal: Long? = null + hlsTotal: Long? = null, + bytesPerSecond: Long ): Notification? { try { if (total <= 0) return null// crash, invalid data @@ -327,22 +327,29 @@ object VideoDownloadManager { val totalMbString: String val suffix: String + val mbFormat = "%.1f MB" + if (hlsProgress != null && hlsTotal != null) { progressPercentage = hlsProgress.toLong() * 100 / hlsTotal progressMbString = hlsProgress.toString() totalMbString = hlsTotal.toString() - suffix = " - %.1f MB".format(progress / 1000000f) + suffix = " - $mbFormat".format(progress / 1000000f) } else { progressPercentage = progress * 100 / total - progressMbString = "%.1f MB".format(progress / 1000000f) - totalMbString = "%.1f MB".format(total / 1000000f) + progressMbString = mbFormat.format(progress / 1000000f) + totalMbString = mbFormat.format(total / 1000000f) suffix = "" } + val mbPerSecondString = + if (state == DownloadType.IsDownloading) { + " ($mbFormat/s)".format(bytesPerSecond.toFloat() / 1000000f) + } else "" + val bigText = when (state) { DownloadType.IsDownloading, DownloadType.IsPaused -> { - (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix" + (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString" } DownloadType.IsFailed -> { @@ -608,6 +615,7 @@ object VideoDownloadManager { val bytesTotal: Long, val hlsProgress: Long? = null, val hlsTotal: Long? = null, + val bytesPerSecond: Long ) data class StreamData( @@ -723,6 +731,7 @@ object VideoDownloadManager { // notification metadata private var lastUpdatedMs: Long = 0, + private var lastDownloadedBytes: Long = 0, private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, private var internalType: DownloadType = DownloadType.IsPending, @@ -738,6 +747,12 @@ object VideoDownloadManager { // this is used for copy with metadata on how much we have downloaded for setKey private var downloadFileInfoTemplate: DownloadedFileInfo? = null ) : Closeable { + fun setResumeLength(length: Long) { + bytesDownloaded = length + bytesWritten = length + lastDownloadedBytes = length + } + val approxTotalBytes: Long get() = totalBytes ?: hlsTotal?.let { total -> (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() @@ -839,6 +854,13 @@ object VideoDownloadManager { @JvmName("DownloadMetaDataNotify") private fun notify() { + // max 10 sec between notifications, min 0.1s, this is to stop div by zero + val dt = (System.currentTimeMillis() - lastUpdatedMs).coerceIn(100, 10000) + + val bytesPerSecond = + ((bytesDownloaded - lastDownloadedBytes) * 1000L) / dt + + lastDownloadedBytes = bytesDownloaded lastUpdatedMs = System.currentTimeMillis() try { val bytes = approxTotalBytes @@ -851,7 +873,8 @@ object VideoDownloadManager { bytesDownloaded, bytes, hlsTotal = hlsTotal?.toLong(), - hlsProgress = hlsProgress.toLong() + hlsProgress = hlsProgress.toLong(), + bytesPerSecond = bytesPerSecond ) ) } else { @@ -860,6 +883,7 @@ object VideoDownloadManager { internalType, bytesDownloaded, bytes, + bytesPerSecond = bytesPerSecond ) ) } @@ -1057,21 +1081,29 @@ object VideoDownloadManager { // we don't want to make a separate connection for every 1kb require(chuckSize > 1000) - val contentLength = + var contentLength = app.head(url = url, headers = headers, referer = referer, verify = false).size + if (contentLength != null && contentLength <= 0) contentLength = null var downloadLength: Long? = null var totalLength: Long? = null val ranges = if (contentLength == null) { + // is the equivalent of [startByte..EOF] as we don't know the size we can only do one + // connection LongArray(1) { startByte } } else { downloadLength = contentLength - startByte totalLength = contentLength - LongArray((downloadLength / chuckSize).toInt()) { idx -> + // div with ceiling as + // this makes the last part "unknown ending" and it will break at EOF + // so eg startByte = 0, downloadLength = 13, chuckSize = 10 + // = LongArray(2) { 0, 10 } = [0,10) + [10..EOF] + LongArray(((downloadLength + chuckSize - 1) / chuckSize).toInt()) { idx -> startByte + idx * chuckSize } } + return LazyStreamDownloadData( url = url, headers = headers, @@ -1158,8 +1190,7 @@ object VideoDownloadManager { val resume = stream.resume ?: return@withContext ERROR_UNKNOWN val fileLength = stream.fileLength ?: return@withContext ERROR_UNKNOWN val resumeAt = (if (resume) fileLength else 0) - metadata.bytesDownloaded = resumeAt - metadata.bytesWritten = resumeAt + metadata.setResumeLength(resumeAt) metadata.type = DownloadType.IsPending val items = streamLazy( @@ -1268,7 +1299,8 @@ object VideoDownloadManager { if (!isActive) return@launch fileMutex.withLock { if (metadata.type == DownloadType.IsStopped - || metadata.type == DownloadType.IsFailed) return@launch + || metadata.type == DownloadType.IsFailed + ) return@launch } // mutex just in case, we never want this to fail due to multithreading @@ -1374,7 +1406,7 @@ object VideoDownloadManager { fileStream = stream.fileStream ?: return@withContext ERROR_UNKNOWN // push the metadata - metadata.bytesDownloaded = stream.fileLength ?: 0 + metadata.setResumeLength(stream.fileLength ?: 0) metadata.hlsProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( @@ -1446,12 +1478,15 @@ object VideoDownloadManager { // if stopped then break to delete if (metadata.type == DownloadType.IsStopped || !isActive) return@launch + val segmentLength = bytes.size.toLong() // send notification, no matter the actual write order - metadata.addSegment(bytes.size.toLong()) + metadata.addSegment(segmentLength) // directly write the bytes if you are first if (metadata.hlsWrittenProgress == index) { fileStream.write(bytes) + + metadata.addBytesWritten(segmentLength) metadata.setWrittenSegment(index) } else { // no need to clone as there will be no modification of this bytearray @@ -1460,12 +1495,14 @@ object VideoDownloadManager { // write the cached bytes submitted by other threads while (true) { - fileStream.write( - pendingData.remove(metadata.hlsWrittenProgress) ?: break - ) + val cache = pendingData.remove(metadata.hlsWrittenProgress) ?: break + val cacheLength = cache.size.toLong() + + fileStream.write(cache) + metadata.addBytesWritten(cacheLength) metadata.setWrittenSegment(metadata.hlsWrittenProgress) } - } catch (t : Throwable) { + } catch (t: Throwable) { // this is in case of write fail if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed @@ -1756,7 +1793,8 @@ object VideoDownloadManager { meta.bytesTotal, notificationCallback, meta.hlsProgress, - meta.hlsTotal + meta.hlsTotal, + meta.bytesPerSecond ) } } @@ -1785,7 +1823,8 @@ object VideoDownloadManager { meta.type, meta.bytesDownloaded, meta.bytesTotal, - notificationCallback + notificationCallback, + bytesPerSecond = meta.bytesPerSecond ) } }) From afcbdeecc86151081aa8a135b3c5900bf1ec8c7e Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 22 Aug 2023 04:00:05 +0200 Subject: [PATCH 22/39] changes to downloader for stable resume --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 6 +- .../ui/settings/SettingsUpdates.kt | 11 +- .../utils/DownloadFileWorkManager.kt | 22 +- .../utils/VideoDownloadManager.kt | 532 ++++++------------ 4 files changed, 191 insertions(+), 380 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index e0d50cc3..9e601fc7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -520,10 +520,10 @@ class GeneratorPlayer : FullScreenPlayer() { if (uri == null) return@normalSafeApiCall val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall // RW perms for the path - val flags = + ctx.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - ctx.contentResolver.takePersistableUriPermission(uri, flags) + ) val file = UniFile.fromUri(ctx, uri) println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index c304629a..62e46c08 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -116,13 +116,14 @@ class SettingsUpdates : PreferenceFragmentCompat() { null, "txt", false - ).fileStream - fileStream?.writer()?.write(text) - } catch (e: Exception) { - logError(e) + ).openNew() + fileStream.writer().write(text) + dialog.dismissSafe(activity) + } catch (t: Throwable) { + logError(t) + showToast(t.message) } finally { fileStream?.closeQuietly() - dialog.dismissSafe(activity) } } binding.closeBtt.setOnClickListener { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index aa424c08..421e4420 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -16,6 +16,7 @@ import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadEpisode import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadFromResume import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadResumePackage import kotlinx.coroutines.delay const val DOWNLOAD_CHECK = "DownloadCheck" @@ -36,15 +37,20 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo WORK_KEY_PACKAGE, key ) + if (info != null) { - downloadEpisode( - applicationContext, - info.source, - info.folder, - info.ep, - info.links, - ::handleNotification - ) + getDownloadResumePackage(applicationContext, info.ep.id)?.let { dpkg -> + downloadFromResume(applicationContext, dpkg, ::handleNotification) + } ?: run { + downloadEpisode( + applicationContext, + info.source, + info.folder, + info.ep, + info.links, + ::handleNotification + ) + } } else if (pkg != null) { downloadFromResume(applicationContext, pkg, ::handleNotification) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 507abc34..a81e4b3a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -9,9 +9,7 @@ import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.os.Environment -import android.provider.MediaStore import androidx.annotation.DrawableRes -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri @@ -32,6 +30,7 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.utils.Coroutines.ioSafe @@ -44,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -301,6 +301,8 @@ object VideoDownloadManager { if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false) + } else if (state == DownloadType.IsPending) { + builder.setProgress(0,0,true) } val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else "" @@ -352,6 +354,10 @@ object VideoDownloadManager { (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString" } + DownloadType.IsPending -> { + (if (linkName == null) "" else "$linkName\n") + rowTwo + } + DownloadType.IsFailed -> { downloadFormat.format( context.getString(R.string.download_failed), @@ -363,7 +369,7 @@ object VideoDownloadManager { downloadFormat.format(context.getString(R.string.download_done), rowTwo) } - else -> { + DownloadType.IsStopped -> { downloadFormat.format( context.getString(R.string.download_canceled), rowTwo @@ -377,7 +383,7 @@ object VideoDownloadManager { } else { val txt = when (state) { - DownloadType.IsDownloading, DownloadType.IsPaused -> { + DownloadType.IsDownloading, DownloadType.IsPaused, DownloadType.IsPending -> { rowTwo } @@ -392,7 +398,7 @@ object VideoDownloadManager { downloadFormat.format(context.getString(R.string.download_done), rowTwo) } - else -> { + DownloadType.IsStopped -> { downloadFormat.format( context.getString(R.string.download_canceled), rowTwo @@ -480,54 +486,6 @@ object VideoDownloadManager { return tempName.replace(" ", " ").trim(' ') } - @RequiresApi(Build.VERSION_CODES.Q) - private fun ContentResolver.getExistingFolderStartName(relativePath: String): List>? { - try { - val projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only) - //MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only) - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath'" - - val result = this.query( - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), - projection, selection, null, null - ) - val list = ArrayList>() - - result.use { c -> - if (c != null && c.count >= 1) { - c.moveToFirst() - while (true) { - val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - val name = - c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val uri = ContentUris.withAppendedId( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, id - ) - list.add(Pair(name, uri)) - if (c.isLast) { - break - } - c.moveToNext() - } - - /* - val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/ - - } - } - return list - } catch (e: Exception) { - logError(e) - return null - } - } - /** * Used for getting video player subs. * @return List of pairs for the files in this format: @@ -538,76 +496,12 @@ object VideoDownloadManager { basePath: String? ): List>? { val base = basePathToFile(context, basePath) - val folder = base?.gotoDir(relativePath, false) + val folder = base?.gotoDir(relativePath, false) ?: return null + if (!folder.isDirectory) return null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { - return context.contentResolver?.getExistingFolderStartName(relativePath) - } else { -// val normalPath = -// "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace( -// '/', -// File.separatorChar -// ) -// val folder = File(normalPath) - if (folder?.isDirectory == true) { - return folder.listFiles()?.map { Pair(it.name ?: "", it.uri) } - } - } - return null -// } + return folder.listFiles()?.map { (it.name ?: "") to it.uri } } - @RequiresApi(Build.VERSION_CODES.Q) - private fun ContentResolver.getExistingDownloadUriOrNullQ( - relativePath: String, - displayName: String - ): Uri? { - try { - val projection = arrayOf( - MediaStore.MediaColumns._ID, - //MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only) - //MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only) - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath' AND " + "${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'" - - val result = this.query( - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), - projection, selection, null, null - ) - - result.use { c -> - if (c != null && c.count >= 1) { - c.moveToFirst().let { - val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - /* - val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/ - - return ContentUris.withAppendedId( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, id - ) - } - } - } - return null - } catch (e: Exception) { - logError(e) - return null - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - fun ContentResolver.getFileLength(fileUri: Uri): Long? { - return try { - this.openFileDescriptor(fileUri, "r") - .use { it?.statSize ?: 0 } - } catch (e: Exception) { - logError(e) - null - } - } data class CreateNotificationMetadata( val type: DownloadType, @@ -619,16 +513,39 @@ object VideoDownloadManager { ) data class StreamData( - val errorCode: Int, - val resume: Boolean? = null, - val fileLength: Long? = null, - val fileStream: OutputStream? = null, - ) + private val fileLength: Long, + val file: UniFile, + //val fileStream: OutputStream, + ) { + fun open() : OutputStream { + return file.openOutputStream(resume) + } + + fun openNew() : OutputStream { + return file.openOutputStream(false) + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() + } + + + //class ADownloadException(val id: Int) : RuntimeException(message = "Download error $id") + + fun UniFile.createFileOrThrow(displayName: String): UniFile { + return this.createFile(displayName) ?: throw IOException("Could not create file") + } + + fun UniFile.deleteOrThrow() { + if (!this.delete()) throw IOException("Could not delete file") + } /** * Sets up the appropriate file and creates a data stream from the file. * Used for initializing downloads. * */ + @Throws(IOException::class) fun setupStream( context: Context, name: String, @@ -637,88 +554,24 @@ object VideoDownloadManager { tryResume: Boolean, ): StreamData { val displayName = getDisplayName(name, extension) - val fileStream: OutputStream - val fileLength: Long - var resume = tryResume - val baseFile = context.getBasePath() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.first?.isDownloadDir() == true) { - val cr = context.contentResolver ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) + val (baseFile, _) = context.getBasePath() - val currentExistingFile = - cr.getExistingDownloadUriOrNullQ( - folder ?: "", - displayName - ) // CURRENT FILE WITH THE SAME PATH + val subDir = baseFile?.gotoDir(folder) ?: throw IOException() + val foundFile = subDir.findFile(displayName) - fileLength = - if (currentExistingFile == null || !resume) 0 else (cr.getFileLength( - currentExistingFile - ) - ?: 0)// IF NOT RESUME THEN 0, OTHERWISE THE CURRENT FILE SIZE - - if (!resume && currentExistingFile != null) { // DELETE FILE IF FILE EXITS AND NOT RESUME - val rowsDeleted = context.contentResolver.delete(currentExistingFile, null, null) - if (rowsDeleted < 1) { - println("ERROR DELETING FILE!!!") - } - } - - var appendFile = false - val newFileUri = if (resume && currentExistingFile != null) { - appendFile = true - currentExistingFile - } else { - val contentUri = - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI - //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - val currentMimeType = when (extension) { - - // Absolutely ridiculous, if text/vtt is used as mimetype scoped storage prevents - // downloading to /Downloads yet it works with null - - "vtt" -> null // "text/vtt" - "mp4" -> "video/mp4" - "srt" -> null // "application/x-subrip"//"text/plain" - else -> null - } - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, name) - if (currentMimeType != null) - put(MediaStore.MediaColumns.MIME_TYPE, currentMimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, folder) - } - - cr.insert( - contentUri, - newFile - ) ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - } - - fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else "")) - ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) + val (file, fileLength) = if (foundFile == null || !foundFile.exists()) { + subDir.createFileOrThrow(displayName) to 0L } else { - val subDir = baseFile.first?.gotoDir(folder) - val rFile = subDir?.findFile(displayName) - if (rFile?.exists() != true) { - fileLength = 0 - if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE) + if (tryResume) { + foundFile to foundFile.size() } else { - if (resume) { - fileLength = rFile.size() - } else { - fileLength = 0 - if (!rFile.delete()) return StreamData(ERROR_DELETING_FILE) - if (subDir.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE) - } + foundFile.deleteOrThrow() + subDir.createFileOrThrow(displayName) to 0L } - fileStream = (subDir.findFile(displayName) - ?: subDir.createFile(displayName))!!.openOutputStream() -// fileStream = FileOutputStream(rFile, false) - if (fileLength == 0L) resume = false } - return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream) + + return StreamData(fileLength, file) } /** This class handles the notifications, as well as the relevant key */ @@ -938,6 +791,8 @@ object VideoDownloadManager { fun setWrittenSegment(segmentIndex: Int) { hlsWrittenProgress = segmentIndex + 1 + // in case of abort we need to save every written progress + updateFileInfo() } } @@ -1185,18 +1040,16 @@ object VideoDownloadManager { // set up the download file val stream = setupStream(context, name, relativePath, extension, tryResume) - if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode - fileStream = stream.fileStream ?: return@withContext ERROR_UNKNOWN - val resume = stream.resume ?: return@withContext ERROR_UNKNOWN - val fileLength = stream.fileLength ?: return@withContext ERROR_UNKNOWN - val resumeAt = (if (resume) fileLength else 0) - metadata.setResumeLength(resumeAt) + + fileStream = stream.open() + + metadata.setResumeLength(stream.startAt) metadata.type = DownloadType.IsPending val items = streamLazy( url = link.url.replace(" ", "%20"), referer = link.referer, - startByte = resumeAt, + startByte = stream.startAt, headers = link.headers.appendAndDontOverride( mapOf( "Accept-Encoding" to "identity", @@ -1230,6 +1083,19 @@ object VideoDownloadManager { val pendingData: HashMap = hashMapOf() + val fileChecker = launch(Dispatchers.IO) { + while (isActive) { + if (stream.exists) { + delay(5000) + continue + } + fileMutex.withLock { + metadata.type = DownloadType.IsStopped + } + break + } + } + val jobs = (0 until parallelConnections).map { launch(Dispatchers.IO) { @@ -1329,9 +1195,11 @@ object VideoDownloadManager { } jobs.join() + fileChecker.cancel() // jobs are finished so we don't want to stop them anymore metadata.removeStopListener() + if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { return@withContext ERROR_CONNECTION_ERROR @@ -1341,11 +1209,8 @@ object VideoDownloadManager { // we need to close before delete fileStream.closeQuietly() metadata.onDelete() - if (deleteFile(context, baseFile, relativePath ?: "", displayName)) { - return@withContext SUCCESS_STOPPED - } else { - return@withContext ERROR_DELETING_FILE - } + deleteFile(context, baseFile, relativePath ?: "", displayName) + return@withContext SUCCESS_STOPPED } metadata.type = DownloadType.IsDone @@ -1400,13 +1265,13 @@ object VideoDownloadManager { folder ) else folder val displayName = getDisplayName(name, extension) - val stream = setupStream(context, name, relativePath, extension, startAt > 0) - if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode - if (stream.resume != true) startAt = 0 - fileStream = stream.fileStream ?: return@withContext ERROR_UNKNOWN + val stream = + setupStream(context, name, relativePath, extension, startAt > 0) + if (!stream.resume) startAt = 0 + fileStream = stream.open() // push the metadata - metadata.setResumeLength(stream.fileLength ?: 0) + metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( @@ -1433,13 +1298,25 @@ object VideoDownloadManager { metadata.hlsTotal = items.size metadata.type = DownloadType.IsDownloading - val currentMutex = Mutex() - val current = (0 until items.size).iterator() + val current = (startAt until items.size).iterator() val fileMutex = Mutex() val pendingData: HashMap = hashMapOf() + val fileChecker = launch(Dispatchers.IO) { + while (isActive) { + if (stream.exists) { + delay(5000) + continue + } + fileMutex.withLock { + metadata.type = DownloadType.IsStopped + } + break + } + } + // see @downloadexplanation for explanation of this download strategy, // this keeps all jobs working at all times, // does several connections in parallel instead of a regular for loop to improve @@ -1476,7 +1353,7 @@ object VideoDownloadManager { // user pause while (metadata.type == DownloadType.IsPaused) delay(100) // if stopped then break to delete - if (metadata.type == DownloadType.IsStopped || !isActive) return@launch + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch val segmentLength = bytes.size.toLong() // send notification, no matter the actual write order @@ -1499,11 +1376,13 @@ object VideoDownloadManager { val cacheLength = cache.size.toLong() fileStream.write(cache) + metadata.addBytesWritten(cacheLength) metadata.setWrittenSegment(metadata.hlsWrittenProgress) } } catch (t: Throwable) { // this is in case of write fail + logError(t) if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed } @@ -1520,9 +1399,12 @@ object VideoDownloadManager { } jobs.join() + fileChecker.cancel() metadata.removeStopListener() + if (!stream.exists) metadata.type = DownloadType.IsStopped + if (metadata.type == DownloadType.IsFailed) { return@withContext ERROR_CONNECTION_ERROR } @@ -1531,11 +1413,8 @@ object VideoDownloadManager { // we need to close before delete fileStream.closeQuietly() metadata.onDelete() - if (deleteFile(context, baseFile, relativePath ?: "", displayName)) { - return@withContext SUCCESS_STOPPED - } else { - return@withContext ERROR_DELETING_FILE - } + deleteFile(context, baseFile, relativePath ?: "", displayName) + return@withContext SUCCESS_STOPPED } metadata.type = DownloadType.IsDone @@ -1564,6 +1443,11 @@ object VideoDownloadManager { directoryName: String?, createMissingDirectories: Boolean = true ): UniFile? { + if(directoryName == null) return this + + return directoryName.split(File.separatorChar).filter { it.isNotBlank() }.fold(this) { file: UniFile?, directory -> + file?.createDirectory(directory) + } // May give this error on scoped storage. // W/DocumentsContract: Failed to create document @@ -1571,7 +1455,7 @@ object VideoDownloadManager { // Not present in latest testing. -// println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}") + println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}") try { // Creates itself from parent if doesn't exist. @@ -1671,49 +1555,6 @@ object VideoDownloadManager { return this != null && this.filePath == getDownloadDir()?.filePath } - /*private fun delete( - context: Context, - name: String, - folder: String?, - extension: String, - parentId: Int?, - basePath: UniFile? - ): Int { - val displayName = getDisplayName(name, extension) - - // delete all subtitle files - if (extension != "vtt" && extension != "srt") { - try { - delete(context, name, folder, "vtt", parentId, basePath) - delete(context, name, folder, "srt", parentId, basePath) - } catch (e: Exception) { - logError(e) - } - } - - // If scoped storage and using download dir (not accessible with UniFile) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.isDownloadDir()) { - val relativePath = getRelativePath(folder) - val lastContent = - context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName) ?: return ERROR_DELETING_FILE - if(context.contentResolver.delete(lastContent, null, null) <= 0) { - return ERROR_DELETING_FILE - } - } else { - val dir = basePath?.gotoDir(folder) - val file = dir?.findFile(displayName) - val success = file?.delete() - if (success != true) return ERROR_DELETING_FILE else { - // Cleans up empty directory - if (dir.listFiles()?.isEmpty() == true) dir.delete() - } - parentId?.let { - downloadDeleteEvent.invoke(parentId) - } - } - return SUCCESS_STOPPED - }*/ - fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { return getFileName(context, metadata.name, metadata.episode, metadata.season) @@ -1765,70 +1606,60 @@ object VideoDownloadManager { } } - if (link.isM3u8 || URL(link.url).path.endsWith(".m3u8")) { - val startIndex = if (tryResume) { - context.getKey( - KEY_DOWNLOAD_INFO, - ep.id.toString(), - null - )?.extraInfo?.toIntOrNull() - } else null - return suspendSafeApiCall { - downloadHLS( + val callback: (CreateNotificationMetadata) -> Unit = { meta -> + main { + createNotification( + context, + source, + link.name, + ep, + meta.type, + meta.bytesDownloaded, + meta.bytesTotal, + notificationCallback, + meta.hlsProgress, + meta.hlsTotal, + meta.bytesPerSecond + ) + } + } + + try { + if (link.isM3u8 || normalSafeApiCall { URL(link.url).path.endsWith(".m3u8") } == true) { + val startIndex = if (tryResume) { + context.getKey( + KEY_DOWNLOAD_INFO, + ep.id.toString(), + null + )?.extraInfo?.toIntOrNull() + } else null + + return downloadHLS( context, link, name, folder, ep.id, startIndex, - createNotificationCallback = { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback, - meta.hlsProgress, - meta.hlsTotal, - meta.bytesPerSecond - ) - } - } + callback ) - }.also { - extractorJob.cancel() - } ?: ERROR_UNKNOWN + } else { + return downloadThing( + context, + link, + name, + folder, + "mp4", + tryResume, + ep.id, + callback + ) + } + } catch (t: Throwable) { + return ERROR_UNKNOWN + } finally { + extractorJob.cancel() } - - return suspendSafeApiCall { - downloadThing( - context, - link, - name, - folder, - "mp4", - tryResume, - ep.id, - createNotificationCallback = { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback, - bytesPerSecond = meta.bytesPerSecond - ) - } - }) - }.also { extractorJob.cancel() } ?: ERROR_UNKNOWN } suspend fun downloadCheck( @@ -1911,26 +1742,10 @@ object VideoDownloadManager { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null val base = basePathToFile(context, info.basePath) + val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName) + if (file?.exists() != true) return null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { - val cr = context.contentResolver ?: return null - val fileUri = - cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) - ?: return null - val fileLength = cr.getFileLength(fileUri) ?: return null - if (fileLength == 0L) return null - return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri) - } else { - - val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName) - -// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) -// val dFile = File(normalPath) - - if (file?.exists() != true) return null - - return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri) - } + return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri) } catch (e: Exception) { logError(e) return null @@ -1943,6 +1758,7 @@ object VideoDownloadManager { fun UniFile.size(): Long { val len = length() return if (len <= 1) { + println("LEN:::::::>>>>>>>>>>>>>>>>>>>>>>>$len") val inputStream = this.openInputStream() return inputStream.available().toLong().also { inputStream.closeQuietly() } } else { @@ -1962,32 +1778,20 @@ object VideoDownloadManager { relativePath: String, displayName: String ): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && folder.isDownloadDir()) { - val cr = context.contentResolver ?: return false - val fileUri = - cr.getExistingDownloadUriOrNullQ(relativePath, displayName) - ?: return true // FILE NOT FOUND, ALREADY DELETED - - return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0 - } else { - val file = folder?.gotoDir(relativePath)?.findFile(displayName) -// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) -// val dFile = File(normalPath) - if (file?.exists() != true) return true - return try { - file.delete() - } catch (e: Exception) { - logError(e) - val cr = context.contentResolver - cr.delete(file.uri, null, null) > 0 - } + val file = folder?.gotoDir(relativePath)?.findFile(displayName) ?: return false + if (!file.exists()) return true + return try { + file.delete() + } catch (e: Exception) { + logError(e) + (context.contentResolver?.delete(file.uri, null, null) ?: return false) > 0 } } private fun deleteFile(context: Context, id: Int): Boolean { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false - downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) + downloadEvent.invoke(id to DownloadActionType.Stop) downloadProgressEvent.invoke(Triple(id, 0, 0)) downloadStatusEvent.invoke(id to DownloadType.IsStopped) downloadDeleteEvent.invoke(id) From 3ea6b1a8d507899ae5a9b295e9f29ded7e0e0448 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Wed, 23 Aug 2023 06:25:06 +0200 Subject: [PATCH 23/39] fixed resume download + migrated filesystem to SafeFile --- .../ui/player/DownloadedPlayerActivity.kt | 4 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 8 +- .../ui/settings/SettingsGeneral.kt | 42 +- .../cloudstream3/utils/BackupUtils.kt | 23 +- .../utils/VideoDownloadManager.kt | 378 +++++++----------- .../cloudstream3/utils/storage/MediaFile.kt | 369 +++++++++++++++++ .../cloudstream3/utils/storage/SafeFile.kt | 244 +++++++++++ .../utils/storage/UniFileWrapper.kt | 116 ++++++ 8 files changed, 924 insertions(+), 260 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index 6f40e145..03405faf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -6,11 +6,11 @@ import android.os.Bundle import android.util.Log import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity -import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.storage.SafeFile const val DTAG = "PlayerActivity" @@ -50,7 +50,7 @@ class DownloadedPlayerActivity : AppCompatActivity() { } private fun playUri(uri: Uri) { - val name = UniFile.fromUri(this, uri).name + val name = SafeFile.fromUri(this, uri)?.name() this.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( DownloadFileGenerator( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 9e601fc7..341b4ad3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -52,6 +52,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.storage.SafeFile import kotlinx.coroutines.Job import java.util.* import kotlin.math.abs @@ -525,10 +526,11 @@ class GeneratorPlayer : FullScreenPlayer() { Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) - val file = UniFile.fromUri(ctx, uri) - println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}") + val file = SafeFile.fromUri(ctx, uri) + val fileName = file?.name() + println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName") // DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES - val name = file.name ?: uri.toString() + val name = fileName ?: uri.toString() val subtitleData = SubtitleData( name, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index 2c81ad1f..f46aac9b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -3,9 +3,7 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle -import android.os.Environment import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -13,7 +11,6 @@ import androidx.appcompat.app.AlertDialog import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication.Companion.getKey @@ -41,7 +38,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import java.io.File +import com.lagradost.cloudstream3.utils.storage.SafeFile fun getCurrentLocale(context: Context): String { val res = context.resources @@ -139,8 +136,9 @@ class SettingsGeneral : PreferenceFragmentCompat() { context.contentResolver.takePersistableUriPermission(uri, flags) - val file = UniFile.fromUri(context, uri) - println("Selected URI path: $uri - Full path: ${file.filePath}") + val file = SafeFile.fromUri(context, uri) + val filePath = file?.filePath() + println("Selected URI path: $uri - Full path: $filePath") // Stores the real URI using download_path_key // Important that the URI is stored instead of filepath due to permissions. @@ -149,7 +147,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { // From URI -> File path // File path here is purely for cosmetic purposes in settings - (file.filePath ?: uri.toString()).let { + (filePath ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context) .edit().putString(getString(R.string.download_path_pref), it).apply() } @@ -306,25 +304,23 @@ class SettingsGeneral : PreferenceFragmentCompat() { } return@setOnPreferenceClickListener true } + fun getDownloadDirs(): List { return normalSafeApiCall { - val defaultDir = VideoDownloadManager.getDownloadDir()?.filePath + context?.let { ctx -> + val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() - // app_name_download_path = Cloudstream and does not change depending on release. - // DOES NOT WORK ON SCOPED STORAGE. - val secondaryDir = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) null else Environment.getExternalStorageDirectory().absolutePath + - File.separator + resources.getString(R.string.app_name_download_path) - val first = listOf(defaultDir, secondaryDir) - (try { - val currentDir = context?.getBasePath()?.let { it.first?.filePath ?: it.second } + val first = listOf(defaultDir) + (try { + val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second } - (first + - requireContext().getExternalFilesDirs("").mapNotNull { it.path } + - currentDir) - } catch (e: Exception) { - first - }).filterNotNull().distinct() + (first + + ctx.getExternalFilesDirs("").mapNotNull { it.path } + + currentDir) + } catch (e: Exception) { + first + }).filterNotNull().distinct() + } } ?: emptyList() } @@ -339,7 +335,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { val currentDir = settingsManager.getString(getString(R.string.download_path_pref), null) - ?: VideoDownloadManager.getDownloadDir().toString() + ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx).toString() } activity?.showBottomDialog( dirs + listOf("Custom"), diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 5bd0cd15..2da54678 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,11 +1,8 @@ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint -import android.content.ContentValues import android.content.Context import android.net.Uri -import android.os.Build -import android.provider.MediaStore import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -36,9 +33,9 @@ import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import com.lagradost.cloudstream3.utils.VideoDownloadManager.isDownloadDir -import java.io.IOException +import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream +import okhttp3.internal.closeQuietly +import java.io.OutputStream import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat @@ -147,6 +144,8 @@ object BackupUtils { @SuppressLint("SimpleDateFormat") fun FragmentActivity.backup() { + var fileStream: OutputStream? = null + var printStream: PrintWriter? = null try { if (!checkWrite()) { showToast(getString(R.string.backup_failed), Toast.LENGTH_LONG) @@ -154,13 +153,16 @@ object BackupUtils { return } - val subDir = getBasePath().first val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) val ext = "json" val displayName = "CS3_Backup_${date}" val backupFile = getBackup() + val stream = setupStream(this, displayName, null, ext, false) + fileStream = stream.openNew() + printStream = PrintWriter(fileStream) + printStream.print(mapper.writeValueAsString(backupFile)) - val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + /*val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && subDir?.isDownloadDir() == true ) { val cr = this.contentResolver @@ -198,7 +200,7 @@ object BackupUtils { val printStream = PrintWriter(steam) printStream.print(mapper.writeValueAsString(backupFile)) - printStream.close() + printStream.close()*/ showToast( R.string.backup_success, @@ -214,6 +216,9 @@ object BackupUtils { } catch (e: Exception) { logError(e) } + } finally { + printStream?.closeQuietly() + fileStream?.closeQuietly() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index a81e4b3a..37c02be4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -8,7 +8,6 @@ import android.content.* import android.graphics.Bitmap import android.net.Uri import android.os.Build -import android.os.Environment import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -20,7 +19,6 @@ 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 import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey @@ -31,19 +29,19 @@ import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.storage.MediaFileContentType +import com.lagradost.cloudstream3.utils.storage.SafeFile import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -160,24 +158,33 @@ object VideoDownloadManager { @JsonProperty("pkg") val pkg: DownloadResumePackage, ) - private const val SUCCESS_DOWNLOAD_DONE = 1 - private const val SUCCESS_STREAM = 3 - private const val SUCCESS_STOPPED = 2 + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) - // will not download the next one, but is still classified as an error - private const val ERROR_DELETING_FILE = 3 - private const val ERROR_CREATE_FILE = -2 - private const val ERROR_UNKNOWN = -10 + /** Invalid input, just skip to the next one as the same args will give the same error */ + private val DOWNLOAD_INVALID_INPUT = + DownloadStatus(retrySame = false, tryNext = true, success = false) - //private const val ERROR_OPEN_FILE = -3 - private const val ERROR_TOO_SMALL_CONNECTION = -4 + /** no need to try any other mirror as we have downloaded the file */ + private val DOWNLOAD_SUCCESS = + DownloadStatus(retrySame = false, tryNext = false, success = true) - //private const val ERROR_WRONG_CONTENT = -5 - private const val ERROR_CONNECTION_ERROR = -6 + /** the user pressed stop, so no need to download anything else */ + private val DOWNLOAD_STOPPED = + DownloadStatus(retrySame = false, tryNext = false, success = true) - //private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7 - //private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8 - private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9 + /** the process failed due to some reason, so we retry and also try the next mirror */ + private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) + + /** bad config, skip all mirrors as every call to download will have the same bad config */ + private val DOWNLOAD_BAD_CONFIG = + DownloadStatus(retrySame = false, tryNext = false, success = false) private const val KEY_RESUME_PACKAGES = "download_resume" const val KEY_DOWNLOAD_INFO = "download_info" @@ -209,15 +216,15 @@ object VideoDownloadManager { } } - /** Will return IsDone if not found or error */ - fun getDownloadState(id: Int): DownloadType { - return try { - downloadStatus[id] ?: DownloadType.IsDone - } catch (e: Exception) { - logError(e) - DownloadType.IsDone - } - } + ///** Will return IsDone if not found or error */ + //fun getDownloadState(id: Int): DownloadType { + // return try { + // downloadStatus[id] ?: DownloadType.IsDone + // } catch (e: Exception) { + // logError(e) + // DownloadType.IsDone + // } + //} private val cachedBitmaps = hashMapOf() fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { @@ -302,7 +309,7 @@ object VideoDownloadManager { if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false) } else if (state == DownloadType.IsPending) { - builder.setProgress(0,0,true) + builder.setProgress(0, 0, true) } val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else "" @@ -496,10 +503,11 @@ object VideoDownloadManager { basePath: String? ): List>? { val base = basePathToFile(context, basePath) - val folder = base?.gotoDir(relativePath, false) ?: return null - if (!folder.isDirectory) return null + val folder = base?.gotoDirectory(relativePath, false) ?: return null + if (folder.isDirectory() != false) return null - return folder.listFiles()?.map { (it.name ?: "") to it.uri } + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } } @@ -514,37 +522,29 @@ object VideoDownloadManager { data class StreamData( private val fileLength: Long, - val file: UniFile, + val file: SafeFile, //val fileStream: OutputStream, ) { - fun open() : OutputStream { - return file.openOutputStream(resume) + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) } - fun openNew() : OutputStream { - return file.openOutputStream(false) + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() } val resume: Boolean get() = fileLength > 0L val startAt: Long get() = if (resume) fileLength else 0L - val exists: Boolean get() = file.exists() + val exists: Boolean get() = file.exists() == true } - //class ADownloadException(val id: Int) : RuntimeException(message = "Download error $id") - - fun UniFile.createFileOrThrow(displayName: String): UniFile { - return this.createFile(displayName) ?: throw IOException("Could not create file") - } - - fun UniFile.deleteOrThrow() { - if (!this.delete()) throw IOException("Could not delete file") - } - - /** - * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads. - * */ @Throws(IOException::class) fun setupStream( context: Context, @@ -552,19 +552,39 @@ object VideoDownloadManager { folder: String?, extension: String, tryResume: Boolean, + ): StreamData { + val (base, _) = context.getBasePath() + return setupStream( + base ?: throw IOException("Bad config"), + name, + folder, + extension, + tryResume + ) + } + + /** + * Sets up the appropriate file and creates a data stream from the file. + * Used for initializing downloads. + * */ + @Throws(IOException::class) + fun setupStream( + baseFile: SafeFile, + name: String, + folder: String?, + extension: String, + tryResume: Boolean, ): StreamData { val displayName = getDisplayName(name, extension) - val (baseFile, _) = context.getBasePath() - - val subDir = baseFile?.gotoDir(folder) ?: throw IOException() + val subDir = baseFile.gotoDirectoryOrThrow(folder) val foundFile = subDir.findFile(displayName) - val (file, fileLength) = if (foundFile == null || !foundFile.exists()) { + val (file, fileLength) = if (foundFile == null || foundFile.exists() != true) { subDir.createFileOrThrow(displayName) to 0L } else { if (tryResume) { - foundFile to foundFile.size() + foundFile to foundFile.lengthOrThrow() } else { foundFile.deleteOrThrow() subDir.createFileOrThrow(displayName) to 0L @@ -1004,21 +1024,20 @@ object VideoDownloadManager { } } - @Throws suspend fun downloadThing( context: Context, link: IDownloadableMinimum, name: String, - folder: String?, + folder: String, extension: String, tryResume: Boolean, parentId: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit, parallelConnections: Int = 3 - ): Int = withContext(Dispatchers.IO) { + ): DownloadStatus = withContext(Dispatchers.IO) { // we cant download torrents with this implementation, aria2c might be used in the future - if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) { - return@withContext ERROR_UNKNOWN + if (link.url.startsWith("magnet") || link.url.endsWith(".torrent") || parallelConnections < 1) { + return@withContext DOWNLOAD_INVALID_INPUT } var fileStream: OutputStream? = null @@ -1033,13 +1052,10 @@ object VideoDownloadManager { // get the file path val (baseFile, basePath) = context.getBasePath() val displayName = getDisplayName(name, extension) - val relativePath = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath( - folder - ) else folder + if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG // set up the download file - val stream = setupStream(context, name, relativePath, extension, tryResume) + val stream = setupStream(baseFile, name, folder, extension, tryResume) fileStream = stream.open() @@ -1069,7 +1085,7 @@ object VideoDownloadManager { metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( totalBytes = metadata.approxTotalBytes, - relativePath = relativePath ?: "", + relativePath = folder, displayName = displayName, basePath = basePath ) @@ -1202,19 +1218,19 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext ERROR_CONNECTION_ERROR + return@withContext DOWNLOAD_FAILED } if (metadata.type == DownloadType.IsStopped) { // we need to close before delete fileStream.closeQuietly() metadata.onDelete() - deleteFile(context, baseFile, relativePath ?: "", displayName) - return@withContext SUCCESS_STOPPED + stream.delete() + return@withContext DOWNLOAD_STOPPED } metadata.type = DownloadType.IsDone - return@withContext SUCCESS_DOWNLOAD_DONE + return@withContext DOWNLOAD_SUCCESS } catch (e: IOException) { // some sort of IO error, this should not happened // we just rethrow it @@ -1226,7 +1242,7 @@ object VideoDownloadManager { // note that when failing we don't want to delete the file, // only user interaction has that power metadata.type = DownloadType.IsFailed - return@withContext ERROR_CONNECTION_ERROR + return@withContext DOWNLOAD_FAILED } finally { fileStream?.closeQuietly() //requestStream?.closeQuietly() @@ -1234,39 +1250,36 @@ object VideoDownloadManager { } } - @Throws private suspend fun downloadHLS( context: Context, link: ExtractorLink, name: String, - folder: String?, + folder: String, parentId: Int?, startIndex: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit, parallelConnections: Int = 3 - ): Int = withContext(Dispatchers.IO) { - require(parallelConnections >= 1) + ): DownloadStatus = withContext(Dispatchers.IO) { + if (parallelConnections < 1) return@withContext DOWNLOAD_INVALID_INPUT val metadata = DownloadMetaData( createNotificationCallback = createNotificationCallback, id = parentId ) - val extension = "mp4" - var fileStream: OutputStream? = null try { + val extension = "mp4" + // the start .ts index var startAt = startIndex ?: 0 // set up the file data val (baseFile, basePath) = context.getBasePath() - val relativePath = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath( - folder - ) else folder + if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG + val displayName = getDisplayName(name, extension) val stream = - setupStream(context, name, relativePath, extension, startAt > 0) + setupStream(baseFile, name, folder, extension, startAt > 0) if (!stream.resume) startAt = 0 fileStream = stream.open() @@ -1277,7 +1290,7 @@ object VideoDownloadManager { metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( totalBytes = 0, - relativePath = relativePath ?: "", + relativePath = folder, displayName = displayName, basePath = basePath ) @@ -1406,99 +1419,29 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext ERROR_CONNECTION_ERROR + return@withContext DOWNLOAD_FAILED } if (metadata.type == DownloadType.IsStopped) { // we need to close before delete fileStream.closeQuietly() metadata.onDelete() - deleteFile(context, baseFile, relativePath ?: "", displayName) - return@withContext SUCCESS_STOPPED + stream.delete() + return@withContext DOWNLOAD_STOPPED } metadata.type = DownloadType.IsDone - return@withContext SUCCESS_DOWNLOAD_DONE + return@withContext DOWNLOAD_SUCCESS } catch (t: Throwable) { logError(t) metadata.type = DownloadType.IsFailed - return@withContext ERROR_UNKNOWN + return@withContext DOWNLOAD_FAILED } finally { fileStream?.closeQuietly() metadata.close() } } - - /** - * Guarantees a directory is present with the dir name (if createMissingDirectories is true). - * Works recursively when '/' is present. - * Will remove any file with the dir name if present and add directory. - * Will not work if the parent directory does not exist. - * - * @param directoryName if null will use the current path. - * @return UniFile / null if createMissingDirectories = false and folder is not found. - * */ - private fun UniFile.gotoDir( - directoryName: String?, - createMissingDirectories: Boolean = true - ): UniFile? { - if(directoryName == null) return this - - return directoryName.split(File.separatorChar).filter { it.isNotBlank() }.fold(this) { file: UniFile?, directory -> - file?.createDirectory(directory) - } - - // May give this error on scoped storage. - // W/DocumentsContract: Failed to create document - // java.lang.IllegalArgumentException: Parent document isn't a directory - - // Not present in latest testing. - - println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}") - - try { - // Creates itself from parent if doesn't exist. - if (!this.exists() && createMissingDirectories && !this.name.isNullOrBlank()) { - if (this.parentFile != null) { - this.parentFile?.createDirectory(this.name) - } else if (this.filePath != null) { - UniFile.fromFile(File(this.filePath!!).parentFile)?.createDirectory(this.name) - } - } - - val allDirectories = directoryName?.split("/") - return if (allDirectories?.size == 1 || allDirectories == null) { - val found = this.findFile(directoryName) - when { - directoryName.isNullOrBlank() -> this - found?.isDirectory == true -> found - - !createMissingDirectories -> null - // Below creates directories - found?.isFile == true -> { - found.delete() - this.createDirectory(directoryName) - } - - this.isDirectory -> this.createDirectory(directoryName) - else -> this.parentFile?.createDirectory(directoryName) - } - } else { - var currentDirectory = this - allDirectories.forEach { - // If the next directory is not found it returns the deepest directory possible. - val nextDir = currentDirectory.gotoDir(it, createMissingDirectories) - currentDirectory = nextDir ?: return null - } - currentDirectory - } - } catch (e: Exception) { - logError(e) - return null - } - } - private fun getDisplayName(name: String, extension: String): String { return "$name.$extension" } @@ -1510,33 +1453,22 @@ object VideoDownloadManager { * As of writing UniFile is used for everything but download directory on scoped storage. * Special ContentResolver fuckery is needed for that as UniFile doesn't work. * */ - fun getDownloadDir(): UniFile? { + fun getDefaultDir(context: Context): SafeFile? { // See https://www.py4u.net/discuss/614761 - return UniFile.fromFile( - File( - Environment.getExternalStorageDirectory().absolutePath + File.separatorChar + - Environment.DIRECTORY_DOWNLOADS - ) + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads ) } - @Deprecated("TODO fix UniFile to work with download directory.") - private fun getRelativePath(folder: String?): String { - return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace( - '/', - File.separatorChar - ).replace("${File.separatorChar}${File.separatorChar}", File.separatorChar.toString()) - } - /** * Turns a string to an UniFile. Used for stored string paths such as settings. * Should only be used to get a download path. * */ - private fun basePathToFile(context: Context, path: String?): UniFile? { + private fun basePathToFile(context: Context, path: String?): SafeFile? { return when { - path.isNullOrBlank() -> getDownloadDir() - path.startsWith("content://") -> UniFile.fromUri(context, path.toUri()) - else -> UniFile.fromFile(File(path)) + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFile(context, File(path)) } } @@ -1545,17 +1477,12 @@ object VideoDownloadManager { * Returns the file and a string to be stored for future file retrieval. * UniFile.filePath is not sufficient for storage. * */ - fun Context.getBasePath(): Pair { + fun Context.getBasePath(): Pair { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) return basePathToFile(this, basePathSetting) to basePathSetting } - fun UniFile?.isDownloadDir(): Boolean { - return this != null && this.filePath == getDownloadDir()?.filePath - } - - fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { return getFileName(context, metadata.name, metadata.episode, metadata.season) } @@ -1596,7 +1523,7 @@ object VideoDownloadManager { link: ExtractorLink, notificationCallback: (Int, Notification) -> Unit, tryResume: Boolean = false, - ): Int { + ): DownloadStatus { val name = getFileName(context, ep) // Make sure this is cancelled when download is done or cancelled. @@ -1638,7 +1565,7 @@ object VideoDownloadManager { context, link, name, - folder, + folder ?: "", ep.id, startIndex, callback @@ -1648,7 +1575,7 @@ object VideoDownloadManager { context, link, name, - folder, + folder ?: "", "mp4", tryResume, ep.id, @@ -1656,7 +1583,7 @@ object VideoDownloadManager { ) } } catch (t: Throwable) { - return ERROR_UNKNOWN + return DOWNLOAD_FAILED } finally { extractorJob.cancel() } @@ -1698,10 +1625,8 @@ object VideoDownloadManager { notificationCallback, resume ) - //.also { println("Single episode finished with return code: $it") } - // retry every link at least once - if (connectionResult <= 0) { + if (connectionResult.retrySame) { connectionResult = downloadSingleEpisode( context, item.source, @@ -1713,11 +1638,12 @@ object VideoDownloadManager { ) } - if (connectionResult > 0) { // SUCCESS + if (connectionResult.success) { // SUCCESS removeKey(KEY_RESUME_PACKAGES, id.toString()) break - } else if (index == item.links.lastIndex) { + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) + break } } } catch (e: Exception) { @@ -1731,62 +1657,69 @@ object VideoDownloadManager { // return id } - fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { - val res = getDownloadFileInfo(context, id) - if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) - return res + /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { + val res = getDownloadFileInfo(context, id) + if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) + return res + } + */ + fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? = + getDownloadFileInfo(context, id, removeKeys = true) + + private fun DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory(relativePath) + ?.findFile(displayName) } - private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? { + private fun getDownloadFileInfo( + context: Context, + id: Int, + removeKeys: Boolean = false + ): DownloadedFileInfoResult? { try { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null - val base = basePathToFile(context, info.basePath) - val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName) - if (file?.exists() != true) return null + val file = info.toFile(context) - return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri) + // only delete the key if the file is not found + if (file == null || !file.existsOrThrow()) { + if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) + return null + } + + return DownloadedFileInfoResult( + file.lengthOrThrow(), + info.totalBytes, + file.uriOrThrow() + ) } catch (e: Exception) { logError(e) return null } } - /** - * Gets the true download size as Scoped Storage sometimes wrongly returns 0. - * */ - fun UniFile.size(): Long { - val len = length() - return if (len <= 1) { - println("LEN:::::::>>>>>>>>>>>>>>>>>>>>>>>$len") - val inputStream = this.openInputStream() - return inputStream.available().toLong().also { inputStream.closeQuietly() } - } else { - len - } - } - fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { val success = deleteFile(context, id) if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) return success } - private fun deleteFile( + /*private fun deleteFile( context: Context, - folder: UniFile?, + folder: SafeFile?, relativePath: String, displayName: String ): Boolean { - val file = folder?.gotoDir(relativePath)?.findFile(displayName) ?: return false - if (!file.exists()) return true + val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false + if (file.exists() == false) return true return try { file.delete() } catch (e: Exception) { logError(e) - (context.contentResolver?.delete(file.uri, null, null) ?: return false) > 0 + (context.contentResolver?.delete(file.uri() ?: return true, null, null) + ?: return false) > 0 } - } + }*/ private fun deleteFile(context: Context, id: Int): Boolean { val info = @@ -1795,8 +1728,7 @@ object VideoDownloadManager { downloadProgressEvent.invoke(Triple(id, 0, 0)) downloadStatusEvent.invoke(id to DownloadType.IsStopped) downloadDeleteEvent.invoke(id) - val base = basePathToFile(context, info.basePath) - return deleteFile(context, base, info.relativePath, info.displayName) + return info.toFile(context)?.delete() ?: false } fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt new file mode 100644 index 00000000..83c66b8b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt @@ -0,0 +1,369 @@ +package com.lagradost.cloudstream3.utils.storage + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import com.hippo.unifile.UniRandomAccessFile +import com.lagradost.cloudstream3.mvvm.logError +import okhttp3.internal.closeQuietly +import java.io.File +import java.io.InputStream +import java.io.OutputStream + + +enum class MediaFileContentType { + Downloads, + Audio, + Video, + Images, +} + +// https://developer.android.com/training/data-storage/shared/media +fun MediaFileContentType.toPath(): String { + return when (this) { + MediaFileContentType.Downloads -> Environment.DIRECTORY_DOWNLOADS + MediaFileContentType.Audio -> Environment.DIRECTORY_MUSIC + MediaFileContentType.Video -> Environment.DIRECTORY_MOVIES + MediaFileContentType.Images -> Environment.DIRECTORY_DCIM + } +} + +fun MediaFileContentType.defaultPrefix(): String { + return Environment.getExternalStorageDirectory().absolutePath +} + +fun MediaFileContentType.toAbsolutePath(): String { + return defaultPrefix() + File.separator + + this.toPath() +} + +fun replaceDuplicateFileSeparators(path: String): String { + return path.replace(Regex("${File.separator}+"), File.separator) +} + +@RequiresApi(Build.VERSION_CODES.Q) +fun MediaFileContentType.toUri(external: Boolean): Uri { + val volume = if (external) MediaStore.VOLUME_EXTERNAL_PRIMARY else MediaStore.VOLUME_INTERNAL + return when (this) { + MediaFileContentType.Downloads -> MediaStore.Downloads.getContentUri(volume) + MediaFileContentType.Audio -> MediaStore.Audio.Media.getContentUri(volume) + MediaFileContentType.Video -> MediaStore.Video.Media.getContentUri(volume) + MediaFileContentType.Images -> MediaStore.Images.Media.getContentUri(volume) + } +} + +@RequiresApi(Build.VERSION_CODES.Q) +class MediaFile( + private val context: Context, + private val folderType: MediaFileContentType, + private val external: Boolean = true, + absolutePath: String, +) : SafeFile { + // this is the path relative to the download directory so "/hello/text.txt" = "hello/text.txt" is in fact "Download/hello/text.txt" + private val sanitizedAbsolutePath: String = + replaceDuplicateFileSeparators(absolutePath) + + // this is only a directory if the filepath ends with a / + private val isDir: Boolean = sanitizedAbsolutePath.endsWith(File.separator) + private val isFile: Boolean = !isDir + + // this is the relative path including the Download directory, so "/hello/text.txt" => "Download/hello" + private val relativePath: String = + replaceDuplicateFileSeparators(folderType.toPath() + File.separator + sanitizedAbsolutePath).substringBeforeLast( + File.separator + ) + + // "/hello/text.txt" => "text.txt" + private val namePath: String = sanitizedAbsolutePath.substringAfterLast(File.separator) + private val baseUri = folderType.toUri(external) + private val contentResolver: ContentResolver = context.contentResolver + + init { + // some standard asserts that should always be hold or else this class wont work + assert(!relativePath.endsWith(File.separator)) + assert(!(isDir && isFile)) + assert(!relativePath.contains(File.separator + File.separator)) + assert(!namePath.contains(File.separator)) + + if (isDir) { + assert(namePath.isBlank()) + } else { + assert(namePath.isNotBlank()) + } + } + + companion object { + private fun splitFilenameExt(name: String): Pair { + val split = name.indexOfLast { it == '.' } + if (split <= 0) return name to null + val ext = name.substring(split + 1 until name.length) + if (ext.isBlank()) return name to null + + return name.substring(0 until split) to ext + } + + private fun splitFilenameMime(name: String): Pair { + val (display, ext) = splitFilenameExt(name) + val mimeType = when (ext) { + + // Absolutely ridiculous, if text/vtt is used as mimetype scoped storage prevents + // downloading to /Downloads yet it works with null + + "vtt" -> null // "text/vtt" + "mp4" -> "video/mp4" + "srt" -> null // "application/x-subrip"//"text/plain" + else -> null + } + return display to mimeType + } + } + + private fun appendRelativePath(path: String, folder: Boolean): MediaFile? { + if (isFile) return null + + // VideoDownloadManager.sanitizeFilename(path.replace(File.separator, "")) + + val newPath = + sanitizedAbsolutePath + path + if (folder) File.separator else "" + + return MediaFile( + context = context, + folderType = folderType, + external = external, + absolutePath = newPath + ) + } + + private fun createUri(displayName: String? = namePath): Uri? { + if (displayName == null) return null + if (isFile) return null + val (name, mime) = splitFilenameMime(displayName) + + val newFile = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.TITLE, name) + if (mime != null) + put(MediaStore.MediaColumns.MIME_TYPE, mime) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + } + return contentResolver.insert(baseUri, newFile) + } + + override fun createFile(displayName: String?): SafeFile? { + if (isFile || displayName == null) return null + query(displayName)?.uri ?: createUri(displayName) ?: return null + return appendRelativePath(displayName, false) //SafeFile.fromUri(context, ?: return null) + } + + override fun createDirectory(directoryName: String?): SafeFile? { + if (directoryName == null) return null + // we don't create a dir here tbh, just fake create it + return appendRelativePath(directoryName, true) + } + + private data class QueryResult( + val uri: Uri, + val lastModified: Long, + val length: Long, + ) + + @RequiresApi(Build.VERSION_CODES.Q) + private fun query(displayName: String = namePath): QueryResult? { + try { + //val (name, mime) = splitFilenameMime(fullName) + + val projection = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.SIZE, + ) + + val selection = + "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath${File.separator}' AND ${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'" + + contentResolver.query( + baseUri, + projection, selection, null, null + )?.use { cursor -> + while (cursor.moveToNext()) { + val id = + cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + + return QueryResult( + uri = ContentUris.withAppendedId( + baseUri, id + ), + lastModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)), + length = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)), + ) + } + } + } catch (t: Throwable) { + logError(t) + } + + return null + } + + override fun uri(): Uri? { + return query()?.uri + } + + override fun name(): String? { + if (isDir) return null + return namePath + } + + override fun type(): String? { + TODO("Not yet implemented") + } + + override fun filePath(): String { + return replaceDuplicateFileSeparators(relativePath + File.separator + namePath) + } + + override fun isDirectory(): Boolean { + return isDir + } + + override fun isFile(): Boolean { + return isFile + } + + override fun lastModified(): Long? { + if (isDir) return null + return query()?.lastModified + } + + override fun length(): Long? { + if (isDir) return null + val length = query()?.length ?: return null + if(length <= 0) { + val inputStream : InputStream = openInputStream() ?: return null + return try { + inputStream.available().toLong() + } catch (t : Throwable) { + null + } finally { + inputStream.closeQuietly() + } + } + return length + } + + override fun canRead(): Boolean { + TODO("Not yet implemented") + } + + override fun canWrite(): Boolean { + TODO("Not yet implemented") + } + + private fun delete(uri: Uri): Boolean { + return contentResolver.delete(uri, null, null) > 0 + } + + override fun delete(): Boolean { + return if (isDir) { + (listFiles() ?: return false).all { + it.delete() + } + } else { + delete(uri() ?: return false) + } + } + + override fun exists(): Boolean { + if (isDir) return true + return query() != null + } + + override fun listFiles(): List? { + if (isFile) return null + try { + val projection = arrayOf( + MediaStore.MediaColumns.DISPLAY_NAME + ) + + val selection = + "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath${File.separator}'" + contentResolver.query( + baseUri, + projection, selection, null, null + )?.use { cursor -> + val out = ArrayList(cursor.count) + while (cursor.moveToNext()) { + val nameIdx = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + if (nameIdx == -1) continue + val name = cursor.getString(nameIdx) + + appendRelativePath(name, false)?.let { new -> + out.add(new) + } + } + + out + } + } catch (t: Throwable) { + logError(t) + } + return null + } + + override fun findFile(displayName: String?, ignoreCase: Boolean): SafeFile? { + if (isFile || displayName == null) return null + + val new = appendRelativePath(displayName, false) ?: return null + if (new.exists()) { + return new + } + + return null//SafeFile.fromUri(context, query(displayName ?: return null)?.uri ?: return null) + } + + override fun renameTo(name: String?): Boolean { + TODO("Not yet implemented") + } + + override fun openOutputStream(append: Boolean): OutputStream? { + try { + // use current file + uri()?.let { + return contentResolver.openOutputStream( + it, + if (append) "wa" else "wt" + ) + } + + // create a new file if current is not found, + // as we know it is new only write access is needed + createUri()?.let { + return contentResolver.openOutputStream( + it, + "w" + ) + } + return null + } catch (t: Throwable) { + return null + } + } + + override fun openInputStream(): InputStream? { + try { + return contentResolver.openInputStream(uri() ?: return null) + } catch (t: Throwable) { + return null + } + } + + override fun createRandomAccessFile(mode: String?): UniRandomAccessFile? { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt new file mode 100644 index 00000000..9ba0ef88 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt @@ -0,0 +1,244 @@ +package com.lagradost.cloudstream3.utils.storage + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import com.hippo.unifile.UniFile +import com.hippo.unifile.UniRandomAccessFile + +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +interface SafeFile { + companion object { + fun fromUri(context: Context, uri: Uri): SafeFile? { + return UniFileWrapper(UniFile.fromUri(context, uri) ?: return null) + } + + fun fromFile(context: Context, file: File?): SafeFile? { + if (file == null) return null + // because UniFile sucks balls on Media we have to do this + val absPath = file.absolutePath.removePrefix(File.separator) + for (value in MediaFileContentType.values()) { + val prefixes = listOf(value.toAbsolutePath(), value.toPath()) + for (prefix in prefixes) { + if (!absPath.startsWith(prefix)) continue + return fromMedia( + context, + value, + absPath.removePrefix(prefix).ifBlank { File.separator } + ) + } + } + + return UniFileWrapper(UniFile.fromFile(file) ?: return null) + } + + fun fromAsset( + context: Context, + filename: String? + ): SafeFile? { + return UniFileWrapper( + UniFile.fromAsset(context.assets, filename ?: return null) ?: return null + ) + } + + fun fromResource( + context: Context, + id: Int + ): SafeFile? { + return UniFileWrapper( + UniFile.fromResource(context, id) ?: return null + ) + } + + fun fromMedia( + context: Context, + folderType: MediaFileContentType, + path: String = File.separator, + external: Boolean = true, + ): SafeFile? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + //fromUri(context, folderType.toUri(external))?.findFile(folderType.toPath())?.gotoDirectory(path) + + return MediaFile( + context = context, + folderType = folderType, + external = external, + absolutePath = path + ) + } else { + fromFile( + context, + File( + (Environment.getExternalStorageDirectory().absolutePath + File.separator + + folderType.toPath() + File.separator + folderType).replace( + File.separator + File.separator, + File.separator + ) + ) + ) + } + + } + } + + /*val uri: Uri? get() = getUri() + val name: String? get() = getName() + val type: String? get() = getType() + val filePath: String? get() = getFilePath() + val isFile: Boolean? get() = isFile() + val isDirectory: Boolean? get() = isDirectory() + val length: Long? get() = length() + val canRead: Boolean get() = canRead() + val canWrite: Boolean get() = canWrite() + val lastModified: Long? get() = lastModified()*/ + + @Throws(IOException::class) + fun isFileOrThrow(): Boolean { + return isFile() ?: throw IOException("Unable to get if file is a file or directory") + } + + @Throws(IOException::class) + fun lengthOrThrow(): Long { + return length() ?: throw IOException("Unable to get file length") + } + + @Throws(IOException::class) + fun isDirectoryOrThrow(): Boolean { + return isDirectory() ?: throw IOException("Unable to get if file is a directory") + } + + @Throws(IOException::class) + fun filePathOrThrow(): String { + return filePath() ?: throw IOException("Unable to get file path") + } + + @Throws(IOException::class) + fun uriOrThrow(): Uri { + return uri() ?: throw IOException("Unable to get uri") + } + + @Throws(IOException::class) + fun renameOrThrow(name: String?) { + if (!renameTo(name)) { + throw IOException("Unable to rename to $name") + } + } + + @Throws(IOException::class) + fun openOutputStreamOrThrow(append: Boolean = false): OutputStream { + return openOutputStream(append) ?: throw IOException("Unable to open output stream") + } + + @Throws(IOException::class) + fun openInputStreamOrThrow(): InputStream { + return openInputStream() ?: throw IOException("Unable to open input stream") + } + + @Throws(IOException::class) + fun existsOrThrow(): Boolean { + return exists() ?: throw IOException("Unable get if file exists") + } + + @Throws(IOException::class) + fun findFileOrThrow(displayName: String?, ignoreCase: Boolean = false): SafeFile { + return findFile(displayName, ignoreCase) ?: throw IOException("Unable find file") + } + + @Throws(IOException::class) + fun gotoDirectoryOrThrow( + directoryName: String?, + createMissingDirectories: Boolean = true + ): SafeFile { + return gotoDirectory(directoryName, createMissingDirectories) + ?: throw IOException("Unable to go to directory $directoryName") + } + + @Throws(IOException::class) + fun listFilesOrThrow(): List { + return listFiles() ?: throw IOException("Unable to get files") + } + + + @Throws(IOException::class) + fun createFileOrThrow(displayName: String?): SafeFile { + return createFile(displayName) ?: throw IOException("Unable to create file $displayName") + } + + @Throws(IOException::class) + fun createDirectoryOrThrow(directoryName: String?): SafeFile { + return createDirectory( + directoryName ?: throw IOException("Unable to create file with invalid name") + ) + ?: throw IOException("Unable to create directory $directoryName") + } + + @Throws(IOException::class) + fun deleteOrThrow() { + if (!delete()) { + throw IOException("Unable to delete file") + } + } + + /** file.gotoDirectory("a/b/c") -> "file/a/b/c/" where a null or blank directoryName + * returns itself. createMissingDirectories specifies if the dirs should be created + * when travelling or break at a dir not found */ + fun gotoDirectory( + directoryName: String?, + createMissingDirectories: Boolean = true + ): SafeFile? { + if (directoryName == null) return this + + return directoryName.split(File.separatorChar).filter { it.isNotBlank() } + .fold(this) { file: SafeFile?, directory -> + // as MediaFile does not actually create a directory we can do this + if (createMissingDirectories || this is MediaFile) { + file?.createDirectory(directory) + } else { + val next = file?.findFile(directory) + + // we require the file to be a directory + if (next?.isDirectory() != true) { + null + } else { + next + } + } + } + } + + + fun createFile(displayName: String?): SafeFile? + fun createDirectory(directoryName: String?): SafeFile? + fun uri(): Uri? + fun name(): String? + fun type(): String? + fun filePath(): String? + fun isDirectory(): Boolean? + fun isFile(): Boolean? + fun lastModified(): Long? + fun length(): Long? + fun canRead(): Boolean + fun canWrite(): Boolean + fun delete(): Boolean + fun exists(): Boolean? + fun listFiles(): List? + + // fun listFiles(filter: FilenameFilter?): Array? + fun findFile(displayName: String?, ignoreCase: Boolean = false): SafeFile? + + fun renameTo(name: String?): Boolean + + /** Open a stream on to the content associated with the file */ + fun openOutputStream(append: Boolean = false): OutputStream? + + /** Open a stream on to the content associated with the file */ + fun openInputStream(): InputStream? + + /** Get a random access stuff of the UniFile, "r" or "rw" */ + fun createRandomAccessFile(mode: String?): UniRandomAccessFile? +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt new file mode 100644 index 00000000..f1592169 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt @@ -0,0 +1,116 @@ +package com.lagradost.cloudstream3.utils.storage + +import android.net.Uri +import com.hippo.unifile.UniFile +import com.hippo.unifile.UniRandomAccessFile +import com.lagradost.cloudstream3.mvvm.logError +import okhttp3.internal.closeQuietly +import java.io.InputStream +import java.io.OutputStream + +private fun UniFile.toFile(): SafeFile { + return UniFileWrapper(this) +} + +fun safe(apiCall: () -> T): T? { + return try { + apiCall.invoke() + } catch (throwable: Throwable) { + logError(throwable) + null + } +} + +class UniFileWrapper(val file: UniFile) : SafeFile { + override fun createFile(displayName: String?): SafeFile? { + return file.createFile(displayName)?.toFile() + } + + override fun createDirectory(directoryName: String?): SafeFile? { + return file.createDirectory(directoryName)?.toFile() + } + + override fun uri(): Uri? { + return safe { file.uri } + } + + override fun name(): String? { + return safe { file.name } + } + + override fun type(): String? { + return safe { file.type } + } + + override fun filePath(): String? { + return safe { file.filePath } + } + + override fun isDirectory(): Boolean? { + return safe { file.isDirectory } + } + + override fun isFile(): Boolean? { + return safe { file.isFile } + } + + override fun lastModified(): Long? { + return safe { file.lastModified() } + } + + override fun length(): Long? { + return safe { + val len = file.length() + if (len <= 1) { + val inputStream = this.openInputStream() ?: return@safe null + try { + inputStream.available().toLong() + } finally { + inputStream.closeQuietly() + } + } else { + len + } + } + } + + override fun canRead(): Boolean { + return safe { file.canRead() } ?: false + } + + override fun canWrite(): Boolean { + return safe { file.canWrite() } ?: false + } + + override fun delete(): Boolean { + return safe { file.delete() } ?: false + } + + override fun exists(): Boolean? { + return safe { file.exists() } + } + + override fun listFiles(): List? { + return safe { file.listFiles()?.mapNotNull { it?.toFile() } } + } + + override fun findFile(displayName: String?, ignoreCase: Boolean): SafeFile? { + return safe { file.findFile(displayName, ignoreCase)?.toFile() } + } + + override fun renameTo(name: String?): Boolean { + return safe { file.renameTo(name) } ?: return false + } + + override fun openOutputStream(append: Boolean): OutputStream? { + return safe { file.openOutputStream(append) } + } + + override fun openInputStream(): InputStream? { + return safe { file.openInputStream() } + } + + override fun createRandomAccessFile(mode: String?): UniRandomAccessFile? { + return safe { file.createRandomAccessFile(mode) } + } +} \ No newline at end of file From d436171a2f89f58107d5bc48d2a6be2fbb8845eb Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Wed, 23 Aug 2023 06:36:43 +0200 Subject: [PATCH 24/39] removed possible duplicate download queue --- .../lagradost/cloudstream3/utils/VideoDownloadManager.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 37c02be4..442fa32f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -1598,9 +1598,8 @@ object VideoDownloadManager { val item = pkg.item val id = item.ep.id if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(Pair(id, DownloadActionType.Resume)) - /** ID needs to be returned to the work-manager to properly await notification */ - // return id + downloadEvent.invoke(id to DownloadActionType.Resume) + return } currentDownloads.add(id) @@ -1741,14 +1740,14 @@ object VideoDownloadManager { notificationCallback: (Int, Notification) -> Unit, setKey: Boolean = true ) { - if (!currentDownloads.any { it == pkg.item.ep.id }) { + if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { downloadQueue.addLast(pkg) downloadCheck(context, notificationCallback) if (setKey) saveQueue() //ret } else { downloadEvent( - Pair(pkg.item.ep.id, DownloadActionType.Resume) + pkg.item.ep.id to DownloadActionType.Resume ) //null } From bac2ee980551d194b70866fc1580ecf40455e024 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Wed, 23 Aug 2023 17:08:26 +0200 Subject: [PATCH 25/39] fixed div by zero --- .../com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index eb8cb9b3..91e97dfc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -44,6 +44,8 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { private fun fixPlayerSize() { playerWidthHeight?.let { (w, h) -> + if(w <= 0 || h <= 0) return@let + val orientation = context?.resources?.configuration?.orientation ?: return val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { From e2502de02cdf226ab4c37cbc71743c68896f5745 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Wed, 23 Aug 2023 18:43:55 +0200 Subject: [PATCH 26/39] bump acra --- app/build.gradle.kts | 2 +- .../main/java/com/lagradost/cloudstream3/AcraApplication.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 50125aa3..178b49c2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,7 +58,7 @@ android { targetSdk = 33 versionCode = 59 - versionName = "4.1.6" + versionName = "4.1.7" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index 32702657..c14780d8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -43,9 +43,9 @@ class CustomReportSender : ReportSender { override fun send(context: Context, errorContent: CrashReportData) { println("Sending report") val url = - "https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse" + "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse" val data = mapOf( - "entry.753293084" to errorContent.toJSON() + "entry.1993829403" to errorContent.toJSON() ) thread { // to not run it on main thread From 5bad6aca352506c722749fa6c1b5123ae722a071 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Wed, 23 Aug 2023 23:57:54 +0200 Subject: [PATCH 27/39] fixed native crash handle --- .../com/lagradost/cloudstream3/AcraApplication.kt | 11 ++++++++--- .../com/lagradost/cloudstream3/NativeCrashHandler.kt | 11 ++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index c14780d8..4b4747ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -104,13 +104,17 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : } class AcraApplication : Application() { + override fun onCreate() { super.onCreate() NativeCrashHandler.initCrashHandler() - Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) { + ExceptionHandler(filesDir.resolve("last_error")) { val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) startActivity(Intent.makeRestartActivityTask(intent!!.component)) - }) + }.also { + exceptionHandler = it + Thread.setDefaultUncaughtExceptionHandler(it) + } } override fun attachBaseContext(base: Context?) { @@ -138,6 +142,8 @@ class AcraApplication : Application() { } companion object { + var exceptionHandler: ExceptionHandler? = null + /** Use to get activity from Context */ tailrec fun Context.getActivity(): Activity? = this as? Activity ?: (this as? ContextWrapper)?.baseContext?.getActivity() @@ -212,6 +218,5 @@ class AcraApplication : Application() { activity?.supportFragmentManager?.fragments?.lastOrNull() ) } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt index e5cb2702..1fe00748 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt @@ -14,6 +14,12 @@ object NativeCrashHandler { private external fun getSignalStatus(): Int private fun initSignalPolling() = CoroutineScope(Dispatchers.IO).launch { + + //launch { + // delay(10000) + // triggerNativeCrash() + //} + while (true) { delay(10_000) val signal = getSignalStatus() @@ -24,7 +30,10 @@ object NativeCrashHandler { if (lastError != null) continue if (checkSafeModeFile()) continue - throw RuntimeException("Native crash with code: $signal. Try uninstalling extensions.\n") + AcraApplication.exceptionHandler?.uncaughtException( + Thread.currentThread(), + RuntimeException("Native crash with code: $signal. Try uninstalling extensions.\n") + ) } } From 823ffd87080f12791a72d54508bb2393f4444d3c Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 24 Aug 2023 00:25:05 +0200 Subject: [PATCH 28/39] reverted low api crash handle crashing --- app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index 4b4747ae..5f3162b4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -107,7 +107,7 @@ class AcraApplication : Application() { override fun onCreate() { super.onCreate() - NativeCrashHandler.initCrashHandler() + //NativeCrashHandler.initCrashHandler() ExceptionHandler(filesDir.resolve("last_error")) { val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) startActivity(Intent.makeRestartActivityTask(intent!!.component)) From 9a1358e295cf9761d3e8c9bba04ef214dcfc96ca Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Thu, 24 Aug 2023 14:16:33 +0000 Subject: [PATCH 29/39] Lower targetSdk to get all installed packages (#571) --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 178b49c2..dfd2c173 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,7 +55,7 @@ android { defaultConfig { applicationId = "com.lagradost.cloudstream3" minSdk = 21 - targetSdk = 33 + targetSdk = 29 versionCode = 59 versionName = "4.1.7" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 563c82f8..0e716034 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ - + Date: Thu, 24 Aug 2023 16:39:50 +0200 Subject: [PATCH 30/39] fixed removal of predownloaded files --- .../java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt index 9ba0ef88..85a74963 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt @@ -23,7 +23,7 @@ interface SafeFile { // because UniFile sucks balls on Media we have to do this val absPath = file.absolutePath.removePrefix(File.separator) for (value in MediaFileContentType.values()) { - val prefixes = listOf(value.toAbsolutePath(), value.toPath()) + val prefixes = listOf(value.toAbsolutePath(), value.toPath()).map { it.removePrefix(File.separator) } for (prefix in prefixes) { if (!absPath.startsWith(prefix)) continue return fromMedia( From c92ac3e8b3502f26ed812647134dc0977218831e Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 24 Aug 2023 18:13:42 +0200 Subject: [PATCH 31/39] fixed removal of predownloaded files 2 + permission --- app/src/main/AndroidManifest.xml | 2 +- .../java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e716034..15767d7b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ - + Download + if(relativePath == path) return this + val newPath = sanitizedAbsolutePath + path + if (folder) File.separator else "" From 9b4701fe91858c71fa32ecec98c3fb05451dfc6c Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 24 Aug 2023 18:14:54 +0200 Subject: [PATCH 32/39] dont remove keys while this is tested --- .../com/lagradost/cloudstream3/utils/VideoDownloadManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 442fa32f..948d7b8a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -1682,7 +1682,7 @@ object VideoDownloadManager { // only delete the key if the file is not found if (file == null || !file.existsOrThrow()) { - if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) + //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD return null } From 1a4cbcaea048e9d057f9c70e37a7a46330a0203c Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 24 Aug 2023 21:17:42 +0200 Subject: [PATCH 33/39] small fix --- .../ui/result/ResultViewModel2.kt | 4 +-- .../cloudstream3/utils/storage/MediaFile.kt | 27 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index bdd27091..82d9a8fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -591,7 +591,7 @@ class ResultViewModel2 : ViewModel() { link, "$fileName ${link.name}", folder, - if (link.url.contains(".srt")) ".srt" else "vtt", + if (link.url.contains(".srt")) "srt" else "vtt", false, null, createNotificationCallback = {} ) @@ -719,7 +719,7 @@ class ResultViewModel2 : ViewModel() { ) ) } - .map { ExtractorSubtitleLink(it.name, it.url, "") } + .map { ExtractorSubtitleLink(it.name, it.url, "") }.take(3) .forEach { link -> val fileName = VideoDownloadManager.getFileName(context, meta) downloadSubtitle(context, link, fileName, folder) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt index 526d31ca..51b8adfe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt @@ -13,6 +13,7 @@ import com.hippo.unifile.UniRandomAccessFile import com.lagradost.cloudstream3.mvvm.logError import okhttp3.internal.closeQuietly import java.io.File +import java.io.FileNotFoundException import java.io.InputStream import java.io.OutputStream @@ -65,6 +66,10 @@ class MediaFile( private val external: Boolean = true, absolutePath: String, ) : SafeFile { + override fun toString(): String { + return sanitizedAbsolutePath + } + // this is the path relative to the download directory so "/hello/text.txt" = "hello/text.txt" is in fact "Download/hello/text.txt" private val sanitizedAbsolutePath: String = replaceDuplicateFileSeparators(absolutePath) @@ -130,7 +135,7 @@ class MediaFile( // VideoDownloadManager.sanitizeFilename(path.replace(File.separator, "")) // in case of duplicate path, aka Download -> Download - if(relativePath == path) return this + if (relativePath == path) return this val newPath = sanitizedAbsolutePath + path + if (folder) File.separator else "" @@ -246,12 +251,24 @@ class MediaFile( override fun length(): Long? { if (isDir) return null - val length = query()?.length ?: return null - if(length <= 0) { - val inputStream : InputStream = openInputStream() ?: return null + val query = query() + val length = query?.length ?: return null + if (length <= 0) { + try { + contentResolver.openFileDescriptor(query.uri, "r") + .use { + it?.statSize + }?.let { + return it + } + } catch (e: FileNotFoundException) { + return null + } + + val inputStream: InputStream = openInputStream() ?: return null return try { inputStream.available().toLong() - } catch (t : Throwable) { + } catch (t: Throwable) { null } finally { inputStream.closeQuietly() From b38a9b1ff5d6e1c5de21851af089232fca7c985b Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 24 Aug 2023 21:39:05 +0200 Subject: [PATCH 34/39] fuck android --- .../com/lagradost/cloudstream3/utils/VideoDownloadManager.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 948d7b8a..7bd863ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -62,6 +62,7 @@ const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { var maxConcurrentDownloads = 3 + var maxConcurrentConnections = 3 private var currentDownloads = mutableListOf() private const val USER_AGENT = @@ -1568,7 +1569,7 @@ object VideoDownloadManager { folder ?: "", ep.id, startIndex, - callback + callback, parallelConnections = maxConcurrentConnections ) } else { return downloadThing( @@ -1579,7 +1580,7 @@ object VideoDownloadManager { "mp4", tryResume, ep.id, - callback + callback, parallelConnections = maxConcurrentConnections ) } } catch (t: Throwable) { From 2d82480398c2dd5b0dfa5b0c0db7c626658aafd2 Mon Sep 17 00:00:00 2001 From: Sofie <117321707+Sofie99@users.noreply.github.com> Date: Fri, 25 Aug 2023 15:58:58 +0700 Subject: [PATCH 35/39] fix Rabbitstream (#573) Co-authored-by: Sofie99 --- .../com/lagradost/cloudstream3/extractors/Rabbitstream.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Rabbitstream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Rabbitstream.kt index b686f7d8..0154b4e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Rabbitstream.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Rabbitstream.kt @@ -36,7 +36,6 @@ open class Rabbitstream : ExtractorApi() { override val requiresReferer = false open val embed = "ajax/embed-4" open val key = "https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt" - private var rawKey: String? = null override suspend fun getUrl( url: String, @@ -82,9 +81,10 @@ open class Rabbitstream : ExtractorApi() { ) } + } - private suspend fun getRawKey(): String = rawKey ?: app.get(key).text.also { rawKey = it } + private suspend fun getRawKey(): String = app.get(key).text private fun extractRealKey(originalString: String?, stops: String): Pair { val table = parseJson>>(stops) From d0c03321b90b13142dce2ab5931c13db48e4a90d Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 25 Aug 2023 10:59:18 +0200 Subject: [PATCH 36/39] Translations update from Hosted Weblate (#568) Co-authored-by: Carlos Luiz Co-authored-by: Joel Brink Co-authored-by: Julian Co-authored-by: Mubarek Seyd Juhar Co-authored-by: Sam Cooper Co-authored-by: Skrripy Co-authored-by: mbottari Co-authored-by: tabtomi8 --- app/src/main/res/values-ajp/strings.xml | 2 + app/src/main/res/values-am/strings.xml | 5 + app/src/main/res/values-ars/strings.xml | 203 +++++++++++++++++- app/src/main/res/values-bp/strings.xml | 69 +++++- app/src/main/res/values-de/strings.xml | 12 +- app/src/main/res/values-hu/strings.xml | 38 ++-- app/src/main/res/values-ti/strings.xml | 6 + app/src/main/res/values-uk/strings.xml | 4 +- .../metadata/android/ar-SA/changelogs/2.txt | 1 + .../android/ar-SA/full_description.txt | 10 +- .../android/ar-SA/short_description.txt | 2 +- fastlane/metadata/android/ar-SA/title.txt | 2 +- .../android/de-DE/full_description.txt | 6 +- 13 files changed, 321 insertions(+), 39 deletions(-) create mode 100644 app/src/main/res/values-ajp/strings.xml create mode 100644 app/src/main/res/values-am/strings.xml create mode 100644 app/src/main/res/values-ti/strings.xml create mode 100644 fastlane/metadata/android/ar-SA/changelogs/2.txt diff --git a/app/src/main/res/values-ajp/strings.xml b/app/src/main/res/values-ajp/strings.xml new file mode 100644 index 00000000..a6b3daec --- /dev/null +++ b/app/src/main/res/values-ajp/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-am/strings.xml b/app/src/main/res/values-am/strings.xml new file mode 100644 index 00000000..98eb0e0d --- /dev/null +++ b/app/src/main/res/values-am/strings.xml @@ -0,0 +1,5 @@ + + + %s ክፍል %d + ተዋናዮች: %s + \ No newline at end of file diff --git a/app/src/main/res/values-ars/strings.xml b/app/src/main/res/values-ars/strings.xml index 42eba3cc..12d558ad 100644 --- a/app/src/main/res/values-ars/strings.xml +++ b/app/src/main/res/values-ars/strings.xml @@ -1,2 +1,203 @@ - + + لافتة + تغيير مزود + جارى التحميل + بث%s + ملء + تخطي التحميل + تحميل… + ترجمات + إعادة محاولة الاتصال … + %sييبي%d + الحلقة%dسيتم نشرها في + %dي%dس%dد + %dس%dد + %dد + لافتة الحلقة + اللافتة الاساسية + اذهب للخالف + معاينة الخلفية + سرعة(%.2fx) + فتح مع كلاودستريم + الصفحة الاساسية + ...%sابحث + لايوجد بيانات + المزيد من الخيارات + فتح في المتصفح + المتصفح + شاهد الفلم + دفق التورنت + بدأ التنزيل + عشوائي قادم + تشغيل المقطع الدعائي + الأنواع + توقف التنزيل + خطط للمشاهدة + لا يوجد + إعادة المشاهدة + !تم العثور على تحديث جديد +\n%s->%s + %.1f:قدر + %dاقل + كلاودستريم + بحث + التحميلات + اعدادات + ...بحث + الحلقة القادمة + شارك + مشاهدة + في التوقف + مكتمل + توقف + تشغيل البث المباشر + مصادر + تشغيل الحلقة + تم إلغاء التنزيل + تم التنزيل + تنززل + تحميل + عُد + التحميل فشل + استخدم سطوع النظام في مشغل التطبيق بدلاً من التراكب الداكن + تم تحميل ملف النسخ الاحتياطي + البحث المتقدم + إزالة الحدود السوداء + ترجمات + يضيف خيار السرعة في المشغل + انقر نقرا مزدوجا للبحث + انقر نقرًا مزدوجًا للإيقاف المؤقت + اللاعب يبحث عن المبلغ (بالثواني) + اسحب من جانب إلى آخر للتحكم بموقعك في الفيديو + ابدأ الحلقة التالية عندما تنتهي الحلقة الحالية + استخدام سطوع النظام + تحديث مراقبة التقدم + قم بمزامنة تقدم الحلقة الحالية تلقائيًا + اسحب لتغيير الإعدادات + استعادة البيانات من النسخة الاحتياطية + فشل في استعادة البيانات من الملف %s + انقر مرتين على الجانب الأيمن أو الأيسر للبحث للأمام أو للخلف + البيانات المخزنة + اضغط مرتين في المنتصف للتوقف مؤقتًا + أذونات التخزين مفقودة. حاول مرة اخرى. + حدث خطأ أثناء النسخ الاحتياطي %s + بحث + مكتبة + معلومات + التحديثات والنسخ الاحتياطي + يعطيك نتائج البحث مفصولة حسب المزود + يرسل فقط البيانات عن الأعطال + عرض المقطورات + عرض الملصقات من كيتسو + حسابات + لا يرسل أي بيانات + عرض حلقة حشو للأنمي + إخفاء جودة الفيديو المحددة في نتائج البحث + تحديثات البرنامج المساعد التلقائي + البحث تلقائيًا عن التحديثات الجديدة بعد بدء تشغيل التطبيق. + التحديث إلى الإصداراالمسبق + تنزيل المكونات الإضافية تلقائيًا + إعادة عملية الإعداد + ابحث عن تحديثات الإصدار التجريبي بدلاً من الإصدارات الكاملة فقط + حدد الوضع لتصفية تنزيل المكونات الإضافية + قم تلقائيًا بتثبيت جميع المكونات الإضافية التي لم يتم تثبيتها بعد من المستودعات المضافة. + إعدادات ترجمات كرومكاست + وضع إيجينجرافي + انتقد للبحث + نسخ إحتياطي للبيانات + إظهار تحديثات التطبيق + إعدادات ترجمات المشغل + ترجمات كرومكاست + قم بالتمرير لأعلى أو لأسفل على الجانب الأيسر أو الأيمن لتغيير السطوع أو مستوى الصوت + التشغيل التلقائي للحلقة القادمة + تطبيق رواية خفيفة من نفس المطورين + أعط بينيني للمطورين + جيتهب + تطبيق انيمي من نفس المطورين + لغة التطبيق + انضم إلى الديسكورد + بنيني معطا + بعض الهواتف لا تدعم مثبت الحزمة الجديد. جرب الخيار القديم إذا لم يتم تثبيت التحديثات. + مثبت تتبيق + اجتاز + الحلقات + موسم + تم نسخ الرابط إلى الحافظة + مسح + وقف + جارٍ تنزيل تحديث التطبيق… + إعادة التعيين إلى القيمة العادية + س + %d%s + لا يتمتع هذا المزود بدعم كرومكاست + لم يتم العثور على أي روابط + تشغيل الحلقة + عذرًا، تعطل التطبيق. سيتم إرسال تقرير خطأ مجهول إلى المطورين + %s%d%s + لا يوجد موسم + حلقة + %d-%d + يي + امسح التاريخ + جارٍ تثبيت تحديث التطبيق… + بدأ + لم يتم العثور على أي حلقات + إظهار تخطي النوافذ المنبثقة للفتح/الإنهاء + الكثير من النص. غير قادر على الحفظ في الحافظة. + وضع علامة كما شاهدت + إزالة من شاهد + حذف ملف + فشل + اكتمل + -30 + +30 + تاريخ + هل أنت متأكد أنك تريد الخروج؟ + نعم + لا + تعذر تثبيت الإصدار الجديد من التطبيق + إرث + منزل المجموعة + التقييم (من الأقل إلى الأعلى) + تم التحديث (من الجديد إلى القديم) + تم التحديث (القديم إلى الجديد) + أبجديًا (من الألف إلى الياء) + مكتبتك فارغة :( +\nقم بتسجيل الدخول إلى حساب المكتبة أو قم بإضافة العروض إلى مكتبتك المحلية. + !تم العثور على ملف الوضع الآمن +\n.عدم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف + ارجع + تحديث العروض المشتركة + الوضع العادي + حرر + ملفات تعريفية + مساعدة + .هنا يمكنك تغيير كيفية ترتيب المصادر. إذا كان للفيديو أولوية أعلى، فسيظهر في مكان أعلى في تحديد المصدر. مجموع أولوية المصدر وأولوية الجودة هو أولوية الفيديو +\n +\nالمصدر أ: 3 +\nالجودة ب: 7 +\nستكون أولوية الفيديو المدمجة .10 +\n +\n!ملاحظة: إذا كان المجموع 10 أو أكثر، فسيقوم اللاعب تلقائيًا بتخطي التحميل عند تحميل هذا الرابط + لقد صوت بالفعل + أبجديًا (ياء إلى ألف) + ترتيب حسب + مشترك + سيتم تحديث التطبيق عند الخروج + رتب + التقييم (من الأعلى إلى الأقل) + حدد المكتبة + افتع مع + .هذه القائمة فارغة. حاول التبديل إلى واحد آخر + %sتم الاشتراك في + %sتم إلغاء الاشتراك من + !%dتم إصدار الحلقة + خلفية الملف الشخصي + %dملف التعريف + واي فاي + بيانات الجوال + استخدم + %sتعذر إنشاء واجهة المستخدم بشكل صحيح، وهذا خطأ كبير ويجب الإبلاغ عنه على الفور + الصفات + \ No newline at end of file diff --git a/app/src/main/res/values-bp/strings.xml b/app/src/main/res/values-bp/strings.xml index 425293e4..df95d69f 100644 --- a/app/src/main/res/values-bp/strings.xml +++ b/app/src/main/res/values-bp/strings.xml @@ -156,7 +156,7 @@ Não enviar nenhum dado Mostrar episódios de Filler em anime Mostrar trailers - Mostrar posters do kitsu + Mostrar posters do Kitsu Esconder qualidades de vídeo selecionadas nos resultados da Pesquisa Atualizações de plugin automáticas Mostrar atualizações do app @@ -183,7 +183,7 @@ S E Nenhum Episódio encontrado - Deletar Arquivo + Apagar Arquivo Deletar Pausar Retomar @@ -410,15 +410,19 @@ Transferido %d %s com sucesso Tudo %s já transferido Transferência em batch - plugin - plugins + Plugin + Plugins Isto irá apagar todos os repositórios de plugins Apagar repositório Transferir lista de sites a usar Transferido: %d Desativado: %d Não transferido: %d - Adicionar um repositório para instalar extensões de sites + CloudStream não tem fontes instaladas por padrão. Você precisa instalar um site de repositórios. +\n +\nPor causa das limitações do DMCA (Digital Millennium Copyright Act ) feito em nome de Sky UK Limited 🤮nós não podemos adicionar site de repositórios no app. +\n +\nEntre no nosso Discord ou pesquise online. Ver repositórios da comunidade Lista pública Todas as legendas em maiúsculas @@ -455,7 +459,7 @@ Editar Perfis Exibindo Player - procure na Barra de Progresso - remover dos assitidos + Remover dos assistidos Extensões Alfabética(A => Z) Abrir com @@ -468,7 +472,7 @@ Biblioteca Não Trilhas Sonoras - Votação(Baixa para Alta) + Votação (Baixa para Alta) Atualização iniciada Conteúdo +18 Ajuda @@ -476,7 +480,7 @@ Não pudemos instalar a nova versão do App instalador de pacotes Organizar por - Votação(Alta para Baixa) + Votação (Alta para Baixa) Alfabética(Z => A) Qualidade Perfil de plano de fundo @@ -499,4 +503,51 @@ Atualizando shows inscritos Player oculto - Procure na barra de progresso Conteúdo +18 - + Reiniciar + Parar + Marcar como assistido + Aplicativo precisa ser fechado para atualizar + Mostrar popups pulados para abertura e finalização + %d-%d + Player interno + Tamanho + Abrindo + %s %d%s + %d plugins atualizados + Todos as extensões serão desligadas para ajuda se talvez estejam causando algum bug. + Aplicativo não encontrado + Recapitular + Todas as linguagens + Pula %s + Mistura terminada + Modo seguro ligado + Ranquear: %s + Linguagem + Lista de reprodução HLS + Terminando + %d %s + Adicionado em (antigo para novo) + Introdução + plug-ins não foram encontrados no repositório + Repositório não encontrado, verifique o URL e tente usa uma VPN + Descrição + Versão + Autores + Instale a extensão primeiro + Créditos + Historico + Limpar historico + Tem Muito texto. Não é possível salvar no clipboard. + Player de vídeo preferido + Começar + Suportado + Status + MPV + Abrindo mistura + VLC + Aplicar quando reiniciar + Visualização info de crash + Faixas de áudio + Adicionado em (novo para antigo) + Faixas de video + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 45a6a66c..6892c8fd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -92,7 +92,7 @@ Abbrechen Kopieren Schließen - Löschen + Leeren Speichern Player-Geschwindigkeit Untertiteleinstellungen @@ -390,7 +390,7 @@ Einrichtung überspringen Aussehen der App passend zu dem des Geräts ändern Absturzmeldung - Was möchtest du anschauen\? + Was möchten Sie sehen\? Fertig Erweiterungen Repository hinzufügen @@ -546,4 +546,10 @@ \nWerden eine kombinierte Videopriorität von 10 haben. \n \nHINWEIS: Wenn die Summe 10 oder mehr beträgt, überspringt der Player automatisch das Laden, wenn der Link geladen wird! - + Filtermodus für Plugin-Downloads auswählen + Es wurde bereits abgestimmt + Keine Plugins im Repository gefunden + Repository nicht gefunden, überprüfe die URL und probiere eine VPN + Die Benutzeroberfläche konnte nicht korrekt erstellt werden. Dies ist ein schwerwiegender Fehler und sollte sofort gemeldet werden. %s + Deaktivieren + \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 46407f76..ac817db0 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -190,7 +190,7 @@ Adatok eltárolva Hiba a biztonsági mentés során %s Fiókok - Szolgáltatás szerinti keresés eredmények + Szolgáltató szerint elkülönítve adja meg a keresési eredményeket Nem küld adatokat Poszterek megjelenítése Kitsu-ról Kiválasztott videóminőségek elrejtése keresési eredményekbe @@ -198,7 +198,7 @@ Bővítmények automatikus letöltése Automatikusan telepíti az összes még nem telepített bővítményt a hozzáadott tárolókból. Alkalmazás frissítések megjelenítése - Automatikusan keressen új frissítéseket indításkor + Automatikusan keressen új frissítéseket indításkor. Frissítés az előzetes kiadásokhoz (prerelease) Csak előzetesen kiadott frissítések (prerelease) keresése a teljes kiadások helyett Github @@ -232,30 +232,30 @@ Lejátszás böngészőben Feliratok letöltése Újracsatlakozás… - Swipe balra vagy jobbra a videólejátszóban az idő vezérléséhez + Húzd balra vagy jobbra a videólejátszóban az idő vezérléséhez Csúsztassa ujját a beállítások módosításához - Csúsztassa az újját bal vagy jobb oldalon a fényerő vagy hangerő megváltoztatásához + Csúsztassa felfelé vagy lefelé a bal vagy jobb oldalon a fényerő vagy a hangerő megváltoztatásához Biztonsági mentés 0 Banán a fejlesztőknek - Swipe to seek + Húzás a kereséshez Következő epizód automatikus lejátszása Következő epizód lejátszása amikor az aktuális epizód véget ér - Dupla koppintás to seek + Dupla koppintás a kereséshez Dupla koppintás a szüneteltetéshez - Player seek amount + Lejátszó keresési értéke (Másodpercben) Koppintson kétszer a jobb vagy bal oldalra az előre vagy hátra ugráshoz - Koppintson középre a szüneteltetéshez + Koppintson kétszer középen a szüneteltetéshez Rendszer fényerejének használata Rendszer fényerejének használata az appban a sötét átfedés helyett Előrehaladás frissítése Automatikusan szinkronizálja az aktuális epizód előrehaladását - Adatok visszaállítása a biztonsági mentésből + Adatok visszaállítása biztonsági mentésből Biztonsági mentés betöltve Információ Folytatás -30 Frissítés elkezdődött - Nem sikerült visszaállítani az adatok a fájlból %s + Nem sikerült visszaállítani az adatokat a %s fájlból Tárolási engedélyek hiányoznak. Kérjük próbálja újra. Csak összeomlásokról küld adatokat APK Telepítő @@ -280,7 +280,7 @@ DNS HTTPS-en keresztül Böngésző Android TV - kézmozdulatok + Kézmozdulatok frissítés kihagyása Alkalmazásfrissítések Szolgáltatók @@ -496,4 +496,18 @@ HQ %d letöltve Start - + Emulátor elrendezés + Nyomkövetés hozzáadása + Telefon elrendezés + Poszter cím helye + Tegye a címet a poszter alá + Az átugrás mértéke, amikor a lejátszó el van rejtve + Jogi nyilatkozat + Lejátszó megjelenítve - Ugrási Érték + Lejátszó elrejtve - Ugrási Érték + Klónozott oldal + Egy meglévő webhely klónjának hozzáadása, más URL-címmel + TV elrendezés + Automatikus + Az átugrás mértéke, amikor a lejátszó látható + \ No newline at end of file diff --git a/app/src/main/res/values-ti/strings.xml b/app/src/main/res/values-ti/strings.xml new file mode 100644 index 00000000..0f64858d --- /dev/null +++ b/app/src/main/res/values-ti/strings.xml @@ -0,0 +1,6 @@ + + + %s ክፋል %d + ክፋል %d በ ላይ ይወጣል + ተዋሳእቲ፡ %s + \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e0db1c0e..8b1b6c39 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -418,7 +418,7 @@ Почалося завантаження %d %s… Завантажено %d %s Всі %s вже завантажено - Пакетне завантаження + Завантажити пакети плагін плагіни Видалити репозиторій @@ -553,4 +553,4 @@ Репозиторій не знайдено, перевірте URL-адресу та спробуйте VPN Не знайдено жодних плагінів у репозиторії Ви вже проголосували - + \ No newline at end of file diff --git a/fastlane/metadata/android/ar-SA/changelogs/2.txt b/fastlane/metadata/android/ar-SA/changelogs/2.txt new file mode 100644 index 00000000..cc43acf1 --- /dev/null +++ b/fastlane/metadata/android/ar-SA/changelogs/2.txt @@ -0,0 +1 @@ +تمت إضافة سجل التغيير! diff --git a/fastlane/metadata/android/ar-SA/full_description.txt b/fastlane/metadata/android/ar-SA/full_description.txt index 2107b338..9668a9b1 100644 --- a/fastlane/metadata/android/ar-SA/full_description.txt +++ b/fastlane/metadata/android/ar-SA/full_description.txt @@ -1,14 +1,10 @@ -يتيح لك كلاود ستريم -3 بث وتنزيل الأفلام والمسلسلات التلفزيونية والأنيمي. يأتي التطبيق بدون أي إعلانات وتحليلات. و يدعم العديد من مواقع البث الاولي(التريلر) والأفلام والمزيد. وتشمل الميزات: - +يسمح لك كلاود ستريم -3 ببث وتنزيل الأفلام, المسلسلات التلفزيونية, والأنيمي. +يأتي التطبيق بدون أي إعلانات وتحليلات و + يدعم العديد من مواقع البث الاولي(التريلر) ,والأفلام, والمزيد. إشارات مرجعية - -قم بتنزيل ودفق الأفلام والبرامج التلفزيونية والأنيمي - - تنزيلات الترجمة - دعم كروم كاست diff --git a/fastlane/metadata/android/ar-SA/short_description.txt b/fastlane/metadata/android/ar-SA/short_description.txt index f396ff81..7ccd9743 100644 --- a/fastlane/metadata/android/ar-SA/short_description.txt +++ b/fastlane/metadata/android/ar-SA/short_description.txt @@ -1 +1 @@ -بث وتحميل الأفلام والأنمي والمسلسلات التلفزيونية. +بث وتحميل الأفلام, الأنمي, والمسلسلات التلفزيونية. diff --git a/fastlane/metadata/android/ar-SA/title.txt b/fastlane/metadata/android/ar-SA/title.txt index 635e1390..7977b290 100644 --- a/fastlane/metadata/android/ar-SA/title.txt +++ b/fastlane/metadata/android/ar-SA/title.txt @@ -1 +1 @@ -كلاود ستريم +كلاودستريم diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index df314372..ea2a8750 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -1,11 +1,11 @@ -Mit CloudStream-3 kannst du Filme, TV-Serien und Anime streamen und herunterladen. +Mit CloudStream-3 kannst du Filme, TV-Serien und Anime streamen und herunterladen. Die App kommt ganz ohne Werbung und Analytik aus. -Sie unterstützt mehrere Trailer-, Filmseiten und vieles mehr. Integrierte Features: +Sie unterstützt zahlreiche Trailer, Filmseiten und vieles mehr, unter anderem: Lesezeichen -Herunterladen und Streamen von Filmen, Fernsehsendungen und Animes +Herunterladen und Streaming von Filmen, Fernsehsendungen und Animes Downloads von Untertiteln From 557003895b65a7b6e1631489523e1225d56cdc34 Mon Sep 17 00:00:00 2001 From: "recloudstream[bot]" <111277985+recloudstream[bot]@users.noreply.github.com> Date: Fri, 25 Aug 2023 08:59:37 +0000 Subject: [PATCH 37/39] chore(locales): fix locale issues --- .../com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt | 3 +++ app/src/main/res/values-ajp/strings.xml | 2 +- app/src/main/res/values-am/strings.xml | 2 +- app/src/main/res/values-ars/strings.xml | 2 +- app/src/main/res/values-bp/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 2 +- app/src/main/res/values-ti/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- 9 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index f46aac9b..1bd9778e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -54,6 +54,8 @@ fun getCurrentLocale(context: Context): String { // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto val appLanguages = arrayListOf( /* begin language list */ + Triple("", "ajp", "ajp"), + Triple("", "አማርኛ", "am"), Triple("", "العربية", "ar"), Triple("", "ars", "ars"), Triple("", "български", "bg"), @@ -96,6 +98,7 @@ val appLanguages = arrayListOf( Triple("", "Soomaaliga", "so"), Triple("", "svenska", "sv"), Triple("", "தமிழ்", "ta"), + Triple("", "ትግርኛ", "ti"), Triple("", "Tagalog", "tl"), Triple("", "Türkçe", "tr"), Triple("", "українська", "uk"), diff --git a/app/src/main/res/values-ajp/strings.xml b/app/src/main/res/values-ajp/strings.xml index a6b3daec..42eba3cc 100644 --- a/app/src/main/res/values-ajp/strings.xml +++ b/app/src/main/res/values-ajp/strings.xml @@ -1,2 +1,2 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-am/strings.xml b/app/src/main/res/values-am/strings.xml index 98eb0e0d..5a799eb4 100644 --- a/app/src/main/res/values-am/strings.xml +++ b/app/src/main/res/values-am/strings.xml @@ -2,4 +2,4 @@ %s ክፍል %d ተዋናዮች: %s - \ No newline at end of file + diff --git a/app/src/main/res/values-ars/strings.xml b/app/src/main/res/values-ars/strings.xml index 12d558ad..ea8aa05c 100644 --- a/app/src/main/res/values-ars/strings.xml +++ b/app/src/main/res/values-ars/strings.xml @@ -200,4 +200,4 @@ استخدم %sتعذر إنشاء واجهة المستخدم بشكل صحيح، وهذا خطأ كبير ويجب الإبلاغ عنه على الفور الصفات - \ No newline at end of file + diff --git a/app/src/main/res/values-bp/strings.xml b/app/src/main/res/values-bp/strings.xml index df95d69f..b70eec12 100644 --- a/app/src/main/res/values-bp/strings.xml +++ b/app/src/main/res/values-bp/strings.xml @@ -550,4 +550,4 @@ Faixas de áudio Adicionado em (novo para antigo) Faixas de video - \ No newline at end of file + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6892c8fd..6739465a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -552,4 +552,4 @@ Repository nicht gefunden, überprüfe die URL und probiere eine VPN Die Benutzeroberfläche konnte nicht korrekt erstellt werden. Dies ist ein schwerwiegender Fehler und sollte sofort gemeldet werden. %s Deaktivieren - \ No newline at end of file + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ac817db0..05a7f0a7 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -510,4 +510,4 @@ TV elrendezés Automatikus Az átugrás mértéke, amikor a lejátszó látható - \ No newline at end of file + diff --git a/app/src/main/res/values-ti/strings.xml b/app/src/main/res/values-ti/strings.xml index 0f64858d..a9079ed5 100644 --- a/app/src/main/res/values-ti/strings.xml +++ b/app/src/main/res/values-ti/strings.xml @@ -3,4 +3,4 @@ %s ክፋል %d ክፋል %d በ ላይ ይወጣል ተዋሳእቲ፡ %s - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8b1b6c39..4866ecd4 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -553,4 +553,4 @@ Репозиторій не знайдено, перевірте URL-адресу та спробуйте VPN Не знайдено жодних плагінів у репозиторії Ви вже проголосували - \ No newline at end of file + From 8193e39b3046192dfb6970e5ff49f30d629d033a Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Fri, 25 Aug 2023 23:16:34 +0200 Subject: [PATCH 38/39] bump + refactor --- app/build.gradle.kts | 15 +- .../lagradost/cloudstream3/MainActivity.kt | 18 +- .../cloudstream3/NativeCrashHandler.kt | 4 +- .../ui/player/DownloadedPlayerActivity.kt | 8 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 6 +- .../ui/settings/SettingsGeneral.kt | 4 +- .../cloudstream3/utils/BackupUtils.kt | 1 + .../utils/VideoDownloadManager.kt | 14 +- .../cloudstream3/utils/storage/MediaFile.kt | 389 ------------------ .../cloudstream3/utils/storage/SafeFile.kt | 244 ----------- .../utils/storage/UniFileWrapper.kt | 116 ------ 11 files changed, 40 insertions(+), 779 deletions(-) delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dfd2c173..333fbfb8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,11 +32,12 @@ android { enable = true } - externalNativeBuild { - cmake { - path("CMakeLists.txt") - } - } + // disable this for now + //externalNativeBuild { + // cmake { + // path("CMakeLists.txt") + // } + //} signingConfigs { create("prerelease") { @@ -58,7 +59,7 @@ android { targetSdk = 29 versionCode = 59 - versionName = "4.1.7" + versionName = "4.1.8" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") @@ -232,7 +233,7 @@ dependencies { // To fix SSL fuckery on android 9 implementation("org.conscrypt:conscrypt-android:2.2.1") // Util to skip the URI file fuckery 🙏 - implementation("com.github.tachiyomiorg:unifile:17bec43") + implementation("com.github.LagradOst:SafeFile:0.0.2") // API because cba maintaining it myself implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0") diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 15b16078..fbad4fce 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -144,6 +144,7 @@ import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ResponseParser +import com.lagradost.safefile.SafeFile import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File @@ -279,6 +280,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { companion object { const val TAG = "MAINACT" var lastError: String? = null + /** * Setting this will automatically enter the query in the search * next time the search fragment is opened. @@ -366,7 +368,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { nextSearchQuery = try { URLDecoder.decode(query, "UTF-8") - } catch (t : Throwable) { + } catch (t: Throwable) { logError(t) query } @@ -859,7 +861,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { RecyclerView::class.java.declaredMethods.firstOrNull { it.name == "scrollStep" }?.also { it.isAccessible = true } - } catch (t : Throwable) { + } catch (t: Throwable) { null } } @@ -906,11 +908,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (dx > 0) dx else 0 } - if(!NO_MOVE_LIST) { + if (!NO_MOVE_LIST) { parent.smoothScrollBy(rdx, 0) - }else { + } else { val smoothScroll = reflectedScroll - if(smoothScroll == null) { + if (smoothScroll == null) { parent.smoothScrollBy(rdx, 0) } else { try { @@ -920,12 +922,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { val out = IntArray(2) smoothScroll.invoke(parent, rdx, 0, out) val scrolledX = out[0] - if(abs(scrolledX) <= 0) { // newFocus.measuredWidth*2 + if (abs(scrolledX) <= 0) { // newFocus.measuredWidth*2 smoothScroll.invoke(parent, -rdx, 0, out) parent.smoothScrollBy(scrolledX, 0) if (NO_MOVE_LIST) targetDx = scrolledX } - } catch (t : Throwable) { + } catch (t: Throwable) { parent.smoothScrollBy(rdx, 0) } } @@ -1131,10 +1133,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { snackbar.show() } } - } } + ioSafe { SafeFile.check(this@MainActivity) } if (PluginManager.checkSafeModeFile()) { normalSafeApiCall { diff --git a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt index 1fe00748..7be90440 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.launch object NativeCrashHandler { // external fun triggerNativeCrash() - private external fun initNativeCrashHandler() + /*private external fun initNativeCrashHandler() private external fun getSignalStatus(): Int private fun initSignalPolling() = CoroutineScope(Dispatchers.IO).launch { @@ -49,5 +49,5 @@ object NativeCrashHandler { } initSignalPolling() - } + }*/ } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index 03405faf..d181e175 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.player +import android.content.ContentUris import android.content.Intent import android.net.Uri import android.os.Bundle @@ -10,7 +11,7 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.storage.SafeFile +import com.lagradost.safefile.SafeFile const val DTAG = "PlayerActivity" @@ -57,7 +58,10 @@ class DownloadedPlayerActivity : AppCompatActivity() { listOf( ExtractorUri( uri = uri, - name = name ?: getString(R.string.downloaded_file) + name = name ?: getString(R.string.downloaded_file), + // well not the same as a normal id, but we take it as users may want to + // play downloaded files and save the location + id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()?.hashCode() ) ) ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 341b4ad3..2b9304b6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -21,13 +21,11 @@ import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes -import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding -import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding import com.lagradost.cloudstream3.mvvm.* @@ -52,7 +50,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.storage.SafeFile +import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job import java.util.* import kotlin.math.abs @@ -136,7 +134,7 @@ class GeneratorPlayer : FullScreenPlayer() { return durPos.position } - var currentVerifyLink: Job? = null + private var currentVerifyLink: Job? = null private fun loadExtractorJob(extractorLink: ExtractorLink?) { currentVerifyLink?.cancel() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index f46aac9b..49f678c4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -38,7 +38,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import com.lagradost.cloudstream3.utils.storage.SafeFile +import com.lagradost.safefile.SafeFile fun getCurrentLocale(context: Context): String { val res = context.resources @@ -335,7 +335,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { val currentDir = settingsManager.getString(getString(R.string.download_path_pref), null) - ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx).toString() } + ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf("Custom"), diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 2da54678..41326eb8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -158,6 +158,7 @@ object BackupUtils { val displayName = "CS3_Backup_${date}" val backupFile = getBackup() val stream = setupStream(this, displayName, null, ext, false) + fileStream = stream.openNew() printStream = PrintWriter(fileStream) printStream.print(mapper.writeValueAsString(backupFile)) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 7bd863ae..6425ba66 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -35,8 +35,8 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.storage.MediaFileContentType -import com.lagradost.cloudstream3.utils.storage.SafeFile +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -554,9 +554,8 @@ object VideoDownloadManager { extension: String, tryResume: Boolean, ): StreamData { - val (base, _) = context.getBasePath() return setupStream( - base ?: throw IOException("Bad config"), + context.getBasePath().first ?: getDefaultDir(context) ?: throw IOException("Bad config"), name, folder, extension, @@ -1401,7 +1400,12 @@ object VideoDownloadManager { metadata.type = DownloadType.IsFailed } } finally { - fileMutex.unlock() + try { + // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling + fileMutex.unlock() + } catch (t : Throwable) { + logError(t) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt deleted file mode 100644 index 51b8adfe..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt +++ /dev/null @@ -1,389 +0,0 @@ -package com.lagradost.cloudstream3.utils.storage - -import android.content.ContentResolver -import android.content.ContentUris -import android.content.ContentValues -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import androidx.annotation.RequiresApi -import com.hippo.unifile.UniRandomAccessFile -import com.lagradost.cloudstream3.mvvm.logError -import okhttp3.internal.closeQuietly -import java.io.File -import java.io.FileNotFoundException -import java.io.InputStream -import java.io.OutputStream - - -enum class MediaFileContentType { - Downloads, - Audio, - Video, - Images, -} - -// https://developer.android.com/training/data-storage/shared/media -fun MediaFileContentType.toPath(): String { - return when (this) { - MediaFileContentType.Downloads -> Environment.DIRECTORY_DOWNLOADS - MediaFileContentType.Audio -> Environment.DIRECTORY_MUSIC - MediaFileContentType.Video -> Environment.DIRECTORY_MOVIES - MediaFileContentType.Images -> Environment.DIRECTORY_DCIM - } -} - -fun MediaFileContentType.defaultPrefix(): String { - return Environment.getExternalStorageDirectory().absolutePath -} - -fun MediaFileContentType.toAbsolutePath(): String { - return defaultPrefix() + File.separator + - this.toPath() -} - -fun replaceDuplicateFileSeparators(path: String): String { - return path.replace(Regex("${File.separator}+"), File.separator) -} - -@RequiresApi(Build.VERSION_CODES.Q) -fun MediaFileContentType.toUri(external: Boolean): Uri { - val volume = if (external) MediaStore.VOLUME_EXTERNAL_PRIMARY else MediaStore.VOLUME_INTERNAL - return when (this) { - MediaFileContentType.Downloads -> MediaStore.Downloads.getContentUri(volume) - MediaFileContentType.Audio -> MediaStore.Audio.Media.getContentUri(volume) - MediaFileContentType.Video -> MediaStore.Video.Media.getContentUri(volume) - MediaFileContentType.Images -> MediaStore.Images.Media.getContentUri(volume) - } -} - -@RequiresApi(Build.VERSION_CODES.Q) -class MediaFile( - private val context: Context, - private val folderType: MediaFileContentType, - private val external: Boolean = true, - absolutePath: String, -) : SafeFile { - override fun toString(): String { - return sanitizedAbsolutePath - } - - // this is the path relative to the download directory so "/hello/text.txt" = "hello/text.txt" is in fact "Download/hello/text.txt" - private val sanitizedAbsolutePath: String = - replaceDuplicateFileSeparators(absolutePath) - - // this is only a directory if the filepath ends with a / - private val isDir: Boolean = sanitizedAbsolutePath.endsWith(File.separator) - private val isFile: Boolean = !isDir - - // this is the relative path including the Download directory, so "/hello/text.txt" => "Download/hello" - private val relativePath: String = - replaceDuplicateFileSeparators(folderType.toPath() + File.separator + sanitizedAbsolutePath).substringBeforeLast( - File.separator - ) - - // "/hello/text.txt" => "text.txt" - private val namePath: String = sanitizedAbsolutePath.substringAfterLast(File.separator) - private val baseUri = folderType.toUri(external) - private val contentResolver: ContentResolver = context.contentResolver - - init { - // some standard asserts that should always be hold or else this class wont work - assert(!relativePath.endsWith(File.separator)) - assert(!(isDir && isFile)) - assert(!relativePath.contains(File.separator + File.separator)) - assert(!namePath.contains(File.separator)) - - if (isDir) { - assert(namePath.isBlank()) - } else { - assert(namePath.isNotBlank()) - } - } - - companion object { - private fun splitFilenameExt(name: String): Pair { - val split = name.indexOfLast { it == '.' } - if (split <= 0) return name to null - val ext = name.substring(split + 1 until name.length) - if (ext.isBlank()) return name to null - - return name.substring(0 until split) to ext - } - - private fun splitFilenameMime(name: String): Pair { - val (display, ext) = splitFilenameExt(name) - val mimeType = when (ext) { - - // Absolutely ridiculous, if text/vtt is used as mimetype scoped storage prevents - // downloading to /Downloads yet it works with null - - "vtt" -> null // "text/vtt" - "mp4" -> "video/mp4" - "srt" -> null // "application/x-subrip"//"text/plain" - else -> null - } - return display to mimeType - } - } - - private fun appendRelativePath(path: String, folder: Boolean): MediaFile? { - if (isFile) return null - - // VideoDownloadManager.sanitizeFilename(path.replace(File.separator, "")) - - // in case of duplicate path, aka Download -> Download - if (relativePath == path) return this - - val newPath = - sanitizedAbsolutePath + path + if (folder) File.separator else "" - - return MediaFile( - context = context, - folderType = folderType, - external = external, - absolutePath = newPath - ) - } - - private fun createUri(displayName: String? = namePath): Uri? { - if (displayName == null) return null - if (isFile) return null - val (name, mime) = splitFilenameMime(displayName) - - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, name) - if (mime != null) - put(MediaStore.MediaColumns.MIME_TYPE, mime) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - } - return contentResolver.insert(baseUri, newFile) - } - - override fun createFile(displayName: String?): SafeFile? { - if (isFile || displayName == null) return null - query(displayName)?.uri ?: createUri(displayName) ?: return null - return appendRelativePath(displayName, false) //SafeFile.fromUri(context, ?: return null) - } - - override fun createDirectory(directoryName: String?): SafeFile? { - if (directoryName == null) return null - // we don't create a dir here tbh, just fake create it - return appendRelativePath(directoryName, true) - } - - private data class QueryResult( - val uri: Uri, - val lastModified: Long, - val length: Long, - ) - - @RequiresApi(Build.VERSION_CODES.Q) - private fun query(displayName: String = namePath): QueryResult? { - try { - //val (name, mime) = splitFilenameMime(fullName) - - val projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.MediaColumns.SIZE, - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath${File.separator}' AND ${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'" - - contentResolver.query( - baseUri, - projection, selection, null, null - )?.use { cursor -> - while (cursor.moveToNext()) { - val id = - cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - - return QueryResult( - uri = ContentUris.withAppendedId( - baseUri, id - ), - lastModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)), - length = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)), - ) - } - } - } catch (t: Throwable) { - logError(t) - } - - return null - } - - override fun uri(): Uri? { - return query()?.uri - } - - override fun name(): String? { - if (isDir) return null - return namePath - } - - override fun type(): String? { - TODO("Not yet implemented") - } - - override fun filePath(): String { - return replaceDuplicateFileSeparators(relativePath + File.separator + namePath) - } - - override fun isDirectory(): Boolean { - return isDir - } - - override fun isFile(): Boolean { - return isFile - } - - override fun lastModified(): Long? { - if (isDir) return null - return query()?.lastModified - } - - override fun length(): Long? { - if (isDir) return null - val query = query() - val length = query?.length ?: return null - if (length <= 0) { - try { - contentResolver.openFileDescriptor(query.uri, "r") - .use { - it?.statSize - }?.let { - return it - } - } catch (e: FileNotFoundException) { - return null - } - - val inputStream: InputStream = openInputStream() ?: return null - return try { - inputStream.available().toLong() - } catch (t: Throwable) { - null - } finally { - inputStream.closeQuietly() - } - } - return length - } - - override fun canRead(): Boolean { - TODO("Not yet implemented") - } - - override fun canWrite(): Boolean { - TODO("Not yet implemented") - } - - private fun delete(uri: Uri): Boolean { - return contentResolver.delete(uri, null, null) > 0 - } - - override fun delete(): Boolean { - return if (isDir) { - (listFiles() ?: return false).all { - it.delete() - } - } else { - delete(uri() ?: return false) - } - } - - override fun exists(): Boolean { - if (isDir) return true - return query() != null - } - - override fun listFiles(): List? { - if (isFile) return null - try { - val projection = arrayOf( - MediaStore.MediaColumns.DISPLAY_NAME - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath${File.separator}'" - contentResolver.query( - baseUri, - projection, selection, null, null - )?.use { cursor -> - val out = ArrayList(cursor.count) - while (cursor.moveToNext()) { - val nameIdx = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) - if (nameIdx == -1) continue - val name = cursor.getString(nameIdx) - - appendRelativePath(name, false)?.let { new -> - out.add(new) - } - } - - out - } - } catch (t: Throwable) { - logError(t) - } - return null - } - - override fun findFile(displayName: String?, ignoreCase: Boolean): SafeFile? { - if (isFile || displayName == null) return null - - val new = appendRelativePath(displayName, false) ?: return null - if (new.exists()) { - return new - } - - return null//SafeFile.fromUri(context, query(displayName ?: return null)?.uri ?: return null) - } - - override fun renameTo(name: String?): Boolean { - TODO("Not yet implemented") - } - - override fun openOutputStream(append: Boolean): OutputStream? { - try { - // use current file - uri()?.let { - return contentResolver.openOutputStream( - it, - if (append) "wa" else "wt" - ) - } - - // create a new file if current is not found, - // as we know it is new only write access is needed - createUri()?.let { - return contentResolver.openOutputStream( - it, - "w" - ) - } - return null - } catch (t: Throwable) { - return null - } - } - - override fun openInputStream(): InputStream? { - try { - return contentResolver.openInputStream(uri() ?: return null) - } catch (t: Throwable) { - return null - } - } - - override fun createRandomAccessFile(mode: String?): UniRandomAccessFile? { - TODO("Not yet implemented") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt deleted file mode 100644 index 85a74963..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.lagradost.cloudstream3.utils.storage - -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Environment -import com.hippo.unifile.UniFile -import com.hippo.unifile.UniRandomAccessFile - -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream - -interface SafeFile { - companion object { - fun fromUri(context: Context, uri: Uri): SafeFile? { - return UniFileWrapper(UniFile.fromUri(context, uri) ?: return null) - } - - fun fromFile(context: Context, file: File?): SafeFile? { - if (file == null) return null - // because UniFile sucks balls on Media we have to do this - val absPath = file.absolutePath.removePrefix(File.separator) - for (value in MediaFileContentType.values()) { - val prefixes = listOf(value.toAbsolutePath(), value.toPath()).map { it.removePrefix(File.separator) } - for (prefix in prefixes) { - if (!absPath.startsWith(prefix)) continue - return fromMedia( - context, - value, - absPath.removePrefix(prefix).ifBlank { File.separator } - ) - } - } - - return UniFileWrapper(UniFile.fromFile(file) ?: return null) - } - - fun fromAsset( - context: Context, - filename: String? - ): SafeFile? { - return UniFileWrapper( - UniFile.fromAsset(context.assets, filename ?: return null) ?: return null - ) - } - - fun fromResource( - context: Context, - id: Int - ): SafeFile? { - return UniFileWrapper( - UniFile.fromResource(context, id) ?: return null - ) - } - - fun fromMedia( - context: Context, - folderType: MediaFileContentType, - path: String = File.separator, - external: Boolean = true, - ): SafeFile? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - //fromUri(context, folderType.toUri(external))?.findFile(folderType.toPath())?.gotoDirectory(path) - - return MediaFile( - context = context, - folderType = folderType, - external = external, - absolutePath = path - ) - } else { - fromFile( - context, - File( - (Environment.getExternalStorageDirectory().absolutePath + File.separator + - folderType.toPath() + File.separator + folderType).replace( - File.separator + File.separator, - File.separator - ) - ) - ) - } - - } - } - - /*val uri: Uri? get() = getUri() - val name: String? get() = getName() - val type: String? get() = getType() - val filePath: String? get() = getFilePath() - val isFile: Boolean? get() = isFile() - val isDirectory: Boolean? get() = isDirectory() - val length: Long? get() = length() - val canRead: Boolean get() = canRead() - val canWrite: Boolean get() = canWrite() - val lastModified: Long? get() = lastModified()*/ - - @Throws(IOException::class) - fun isFileOrThrow(): Boolean { - return isFile() ?: throw IOException("Unable to get if file is a file or directory") - } - - @Throws(IOException::class) - fun lengthOrThrow(): Long { - return length() ?: throw IOException("Unable to get file length") - } - - @Throws(IOException::class) - fun isDirectoryOrThrow(): Boolean { - return isDirectory() ?: throw IOException("Unable to get if file is a directory") - } - - @Throws(IOException::class) - fun filePathOrThrow(): String { - return filePath() ?: throw IOException("Unable to get file path") - } - - @Throws(IOException::class) - fun uriOrThrow(): Uri { - return uri() ?: throw IOException("Unable to get uri") - } - - @Throws(IOException::class) - fun renameOrThrow(name: String?) { - if (!renameTo(name)) { - throw IOException("Unable to rename to $name") - } - } - - @Throws(IOException::class) - fun openOutputStreamOrThrow(append: Boolean = false): OutputStream { - return openOutputStream(append) ?: throw IOException("Unable to open output stream") - } - - @Throws(IOException::class) - fun openInputStreamOrThrow(): InputStream { - return openInputStream() ?: throw IOException("Unable to open input stream") - } - - @Throws(IOException::class) - fun existsOrThrow(): Boolean { - return exists() ?: throw IOException("Unable get if file exists") - } - - @Throws(IOException::class) - fun findFileOrThrow(displayName: String?, ignoreCase: Boolean = false): SafeFile { - return findFile(displayName, ignoreCase) ?: throw IOException("Unable find file") - } - - @Throws(IOException::class) - fun gotoDirectoryOrThrow( - directoryName: String?, - createMissingDirectories: Boolean = true - ): SafeFile { - return gotoDirectory(directoryName, createMissingDirectories) - ?: throw IOException("Unable to go to directory $directoryName") - } - - @Throws(IOException::class) - fun listFilesOrThrow(): List { - return listFiles() ?: throw IOException("Unable to get files") - } - - - @Throws(IOException::class) - fun createFileOrThrow(displayName: String?): SafeFile { - return createFile(displayName) ?: throw IOException("Unable to create file $displayName") - } - - @Throws(IOException::class) - fun createDirectoryOrThrow(directoryName: String?): SafeFile { - return createDirectory( - directoryName ?: throw IOException("Unable to create file with invalid name") - ) - ?: throw IOException("Unable to create directory $directoryName") - } - - @Throws(IOException::class) - fun deleteOrThrow() { - if (!delete()) { - throw IOException("Unable to delete file") - } - } - - /** file.gotoDirectory("a/b/c") -> "file/a/b/c/" where a null or blank directoryName - * returns itself. createMissingDirectories specifies if the dirs should be created - * when travelling or break at a dir not found */ - fun gotoDirectory( - directoryName: String?, - createMissingDirectories: Boolean = true - ): SafeFile? { - if (directoryName == null) return this - - return directoryName.split(File.separatorChar).filter { it.isNotBlank() } - .fold(this) { file: SafeFile?, directory -> - // as MediaFile does not actually create a directory we can do this - if (createMissingDirectories || this is MediaFile) { - file?.createDirectory(directory) - } else { - val next = file?.findFile(directory) - - // we require the file to be a directory - if (next?.isDirectory() != true) { - null - } else { - next - } - } - } - } - - - fun createFile(displayName: String?): SafeFile? - fun createDirectory(directoryName: String?): SafeFile? - fun uri(): Uri? - fun name(): String? - fun type(): String? - fun filePath(): String? - fun isDirectory(): Boolean? - fun isFile(): Boolean? - fun lastModified(): Long? - fun length(): Long? - fun canRead(): Boolean - fun canWrite(): Boolean - fun delete(): Boolean - fun exists(): Boolean? - fun listFiles(): List? - - // fun listFiles(filter: FilenameFilter?): Array? - fun findFile(displayName: String?, ignoreCase: Boolean = false): SafeFile? - - fun renameTo(name: String?): Boolean - - /** Open a stream on to the content associated with the file */ - fun openOutputStream(append: Boolean = false): OutputStream? - - /** Open a stream on to the content associated with the file */ - fun openInputStream(): InputStream? - - /** Get a random access stuff of the UniFile, "r" or "rw" */ - fun createRandomAccessFile(mode: String?): UniRandomAccessFile? -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt deleted file mode 100644 index f1592169..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.lagradost.cloudstream3.utils.storage - -import android.net.Uri -import com.hippo.unifile.UniFile -import com.hippo.unifile.UniRandomAccessFile -import com.lagradost.cloudstream3.mvvm.logError -import okhttp3.internal.closeQuietly -import java.io.InputStream -import java.io.OutputStream - -private fun UniFile.toFile(): SafeFile { - return UniFileWrapper(this) -} - -fun safe(apiCall: () -> T): T? { - return try { - apiCall.invoke() - } catch (throwable: Throwable) { - logError(throwable) - null - } -} - -class UniFileWrapper(val file: UniFile) : SafeFile { - override fun createFile(displayName: String?): SafeFile? { - return file.createFile(displayName)?.toFile() - } - - override fun createDirectory(directoryName: String?): SafeFile? { - return file.createDirectory(directoryName)?.toFile() - } - - override fun uri(): Uri? { - return safe { file.uri } - } - - override fun name(): String? { - return safe { file.name } - } - - override fun type(): String? { - return safe { file.type } - } - - override fun filePath(): String? { - return safe { file.filePath } - } - - override fun isDirectory(): Boolean? { - return safe { file.isDirectory } - } - - override fun isFile(): Boolean? { - return safe { file.isFile } - } - - override fun lastModified(): Long? { - return safe { file.lastModified() } - } - - override fun length(): Long? { - return safe { - val len = file.length() - if (len <= 1) { - val inputStream = this.openInputStream() ?: return@safe null - try { - inputStream.available().toLong() - } finally { - inputStream.closeQuietly() - } - } else { - len - } - } - } - - override fun canRead(): Boolean { - return safe { file.canRead() } ?: false - } - - override fun canWrite(): Boolean { - return safe { file.canWrite() } ?: false - } - - override fun delete(): Boolean { - return safe { file.delete() } ?: false - } - - override fun exists(): Boolean? { - return safe { file.exists() } - } - - override fun listFiles(): List? { - return safe { file.listFiles()?.mapNotNull { it?.toFile() } } - } - - override fun findFile(displayName: String?, ignoreCase: Boolean): SafeFile? { - return safe { file.findFile(displayName, ignoreCase)?.toFile() } - } - - override fun renameTo(name: String?): Boolean { - return safe { file.renameTo(name) } ?: return false - } - - override fun openOutputStream(append: Boolean): OutputStream? { - return safe { file.openOutputStream(append) } - } - - override fun openInputStream(): InputStream? { - return safe { file.openInputStream() } - } - - override fun createRandomAccessFile(mode: String?): UniRandomAccessFile? { - return safe { file.createRandomAccessFile(mode) } - } -} \ No newline at end of file From f01820059b520912d77be56ce8a39c2530f6f886 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sun, 27 Aug 2023 19:07:08 +0200 Subject: [PATCH 39/39] delete resume watching + delete bookmarks buttons. fixed backup crash --- .../cloudstream3/ui/home/HomeFragment.kt | 6 ++- .../ui/home/HomeParentItemAdapterPreview.kt | 31 ++++++++--- .../cloudstream3/ui/home/HomeViewModel.kt | 36 ++++++++++--- .../cloudstream3/utils/BackupUtils.kt | 53 +++---------------- .../cloudstream3/utils/DataStoreHelper.kt | 12 +++-- 5 files changed, 72 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index fa0b6dfb..b84c619e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -658,12 +658,14 @@ class HomeFragment : Fragment() { return@observeNullable } - bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = { + val (items, delete) = item + + bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = { homeViewModel.expandAndReturn(it) }, dismissCallback = { homeViewModel.popup(null) bottomSheetDialog = null - }) + }, deleteCallback = delete) } homeViewModel.reloadStored() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 13497a99..1d8e1399 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -246,7 +246,7 @@ class HomeParentItemAdapterPreview( private val previewViewpagerText: ViewGroup = itemView.findViewById(R.id.home_preview_viewpager_text) - // private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview) + // private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview) private var resumeHolder: View = itemView.findViewById(R.id.home_watch_holder) private var resumeRecyclerView: RecyclerView = itemView.findViewById(R.id.home_watch_child_recyclerview) @@ -257,7 +257,7 @@ class HomeParentItemAdapterPreview( private var homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account) - private var topPadding : View? = itemView.findViewById(R.id.home_padding) + private var topPadding: View? = itemView.findViewById(R.id.home_padding) private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding) @@ -283,7 +283,11 @@ class HomeParentItemAdapterPreview( item.plot ?: "" homePreviewText.text = item.name - populateChips(homePreviewTags,item.tags ?: emptyList(), R.style.ChipFilledSemiTransparent) + populateChips( + homePreviewTags, + item.tags ?: emptyList(), + R.style.ChipFilledSemiTransparent + ) homePreviewTags.isGone = item.tags.isNullOrEmpty() @@ -413,7 +417,7 @@ class HomeParentItemAdapterPreview( Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH), ) - private val toggleListHolder : ChipGroup? = itemView.findViewById(R.id.home_type_holder) + private val toggleListHolder: ChipGroup? = itemView.findViewById(R.id.home_type_holder) init { previewViewpager.setPageTransformer(HomeScrollTransformer()) @@ -422,8 +426,14 @@ class HomeParentItemAdapterPreview( resumeRecyclerView.adapter = resumeAdapter bookmarkRecyclerView.adapter = bookmarkAdapter - resumeRecyclerView.setLinearListLayout(nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF) - bookmarkRecyclerView.setLinearListLayout(nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF) + resumeRecyclerView.setLinearListLayout( + nextLeft = R.id.nav_rail_view, + nextRight = FOCUS_SELF + ) + bookmarkRecyclerView.setLinearListLayout( + nextLeft = R.id.nav_rail_view, + nextRight = FOCUS_SELF + ) fixPaddingStatusbarMargin(topPadding) @@ -547,7 +557,10 @@ class HomeParentItemAdapterPreview( resumeWatching, false ), 1, false - ) + ), + deleteCallback = { + viewModel.deleteResumeWatching() + } ) } } @@ -572,7 +585,9 @@ class HomeParentItemAdapterPreview( list, false ), 1, false - ) + ), deleteCallback = { + viewModel.deleteBookmarks(list) + } ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index e8cf8863..b27223ec 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -41,6 +41,8 @@ import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllBookmarkedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData @@ -92,6 +94,21 @@ class HomeViewModel : ViewModel() { } } + fun deleteResumeWatching() { + deleteAllResumeStateIds() + loadResumeWatching() + } + + fun deleteBookmarks(list: List) { + list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) } + loadStoredData() + } + + fun deleteBookmarks() { + deleteAllBookmarkedData() + loadStoredData() + } + var repo: APIRepository? = null private val _apiName = MutableLiveData() @@ -394,11 +411,14 @@ class HomeViewModel : ViewModel() { } - private val _popup = MutableLiveData(null) - val popup: LiveData = _popup + private val _popup = MutableLiveData Unit)?>?>(null) + val popup: LiveData Unit)?>?> = _popup - fun popup(list: ExpandableHomepageList?) { - _popup.postValue(list) + fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) { + if (list == null) + _popup.postValue(null) + else + _popup.postValue(list to deleteCallback) } private fun bookmarksUpdated(unused: Boolean) { @@ -436,8 +456,7 @@ class HomeViewModel : ViewModel() { // do nothing } - fun reloadStored() { - loadResumeWatching() + fun loadStoredData() { val list = EnumSet.noneOf(WatchType::class.java) getKey(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let { list.addAll(it) @@ -445,6 +464,11 @@ class HomeViewModel : ViewModel() { loadStoredData(list) } + fun reloadStored() { + loadResumeWatching() + loadStoredData() + } + fun click(load: LoadClickCallback) { loadResult(load.response.url, load.response.apiName, load.action) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 41326eb8..96593769 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -25,6 +25,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_T import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_UNIXTIME_KEY import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_USER_KEY import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi.Companion.OPEN_SUBTITLES_USER_KEY +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs @@ -143,66 +144,26 @@ object BackupUtils { } @SuppressLint("SimpleDateFormat") - fun FragmentActivity.backup() { + fun FragmentActivity.backup() = ioSafe { var fileStream: OutputStream? = null var printStream: PrintWriter? = null try { if (!checkWrite()) { - showToast(getString(R.string.backup_failed), Toast.LENGTH_LONG) + showToast(R.string.backup_failed, Toast.LENGTH_LONG) requestRW() - return + return@ioSafe } val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) - val ext = "json" + val ext = "txt" val displayName = "CS3_Backup_${date}" val backupFile = getBackup() - val stream = setupStream(this, displayName, null, ext, false) + val stream = setupStream(this@backup, displayName, null, ext, false) fileStream = stream.openNew() printStream = PrintWriter(fileStream) printStream.print(mapper.writeValueAsString(backupFile)) - /*val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && subDir?.isDownloadDir() == true - ) { - val cr = this.contentResolver - val contentUri = - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI - //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, displayName) - // While it a json file we store as txt because not - // all file managers support mimetype json - put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") - //put(MediaStore.MediaColumns.RELATIVE_PATH, folder) - } - - val newFileUri = cr.insert( - contentUri, - newFile - ) ?: throw IOException("Error creating file uri") - cr.openOutputStream(newFileUri, "w") - ?: throw IOException("Error opening stream") - } else { - val fileName = "$displayName.$ext" - val rFile = subDir?.findFile(fileName) - if (rFile?.exists() == true) { - rFile.delete() - } - val file = - subDir?.createFile(fileName) - ?: throw IOException("Error creating file") - if (!file.exists()) throw IOException("File does not exist") - file.openOutputStream() - } - - val printStream = PrintWriter(steam) - printStream.print(mapper.writeValueAsString(backupFile)) - printStream.close()*/ - showToast( R.string.backup_success, Toast.LENGTH_LONG @@ -211,7 +172,7 @@ object BackupUtils { logError(e) try { showToast( - getString(R.string.backup_failed_error_format).format(e.toString()), + txt(R.string.backup_failed_error_format, e.toString()), Toast.LENGTH_LONG ) } catch (e: Exception) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 991651dc..2eb2ab01 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -353,6 +353,12 @@ object DataStoreHelper { removeKeys(folder2) } + fun deleteBookmarkedData(id : Int?) { + if (id == null) return + removeKey("$currentAccount/$RESULT_WATCH_STATE", id.toString()) + removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) + } + fun getAllResumeStateIds(): List? { val folder = "$currentAccount/$RESULT_RESUME_WATCHING" return getKeys(folder)?.mapNotNull { @@ -519,12 +525,10 @@ object DataStoreHelper { fun setResultWatchState(id: Int?, status: Int) { if (id == null) return - val folder = "$currentAccount/$RESULT_WATCH_STATE" if (status == WatchType.NONE.internalId) { - removeKey(folder, id.toString()) - removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) + deleteBookmarkedData(id) } else { - setKey(folder, id.toString(), status) + setKey("$currentAccount/$RESULT_WATCH_STATE", id.toString(), status) } }