mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-24 18:42:25 +00:00
Merge branch 'stripdebrid' into cmp-rewrite
This commit is contained in:
commit
ada9bc00f7
77 changed files with 11954 additions and 654 deletions
|
|
@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outDir.resolve("com/nuvio/app/features/debrid").apply {
|
||||||
|
mkdirs()
|
||||||
|
resolve("PremiumizeConfig.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package com.nuvio.app.features.debrid
|
||||||
|
|
|
||||||
|
|object PremiumizeConfig {
|
||||||
|
| const val CLIENT_ID = "${props.getProperty("PREMIUMIZE_CLIENT_ID", "")}"
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
outDir.resolve("com/nuvio/app/core/build").apply {
|
outDir.resolve("com/nuvio/app/core/build").apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
resolve("AppVersionConfig.kt").writeText(
|
resolve("AppVersionConfig.kt").writeText(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
||||||
import com.nuvio.app.features.addons.AddonStorage
|
import com.nuvio.app.features.addons.AddonStorage
|
||||||
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||||
import com.nuvio.app.features.collection.CollectionStorage
|
import com.nuvio.app.features.collection.CollectionStorage
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsStorage
|
||||||
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
||||||
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
||||||
import com.nuvio.app.features.downloads.DownloadsStorage
|
import com.nuvio.app.features.downloads.DownloadsStorage
|
||||||
|
|
@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
SearchHistoryStorage.initialize(applicationContext)
|
SearchHistoryStorage.initialize(applicationContext)
|
||||||
SeasonViewModeStorage.initialize(applicationContext)
|
SeasonViewModeStorage.initialize(applicationContext)
|
||||||
PosterCardStyleStorage.initialize(applicationContext)
|
PosterCardStyleStorage.initialize(applicationContext)
|
||||||
|
DebridSettingsStorage.initialize(applicationContext)
|
||||||
TmdbSettingsStorage.initialize(applicationContext)
|
TmdbSettingsStorage.initialize(applicationContext)
|
||||||
MdbListSettingsStorage.initialize(applicationContext)
|
MdbListSettingsStorage.initialize(applicationContext)
|
||||||
TraktAuthStorage.initialize(applicationContext)
|
TraktAuthStorage.initialize(applicationContext)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
|
import com.nuvio.app.core.sync.decodeSyncBoolean
|
||||||
|
import com.nuvio.app.core.sync.decodeSyncInt
|
||||||
|
import com.nuvio.app.core.sync.decodeSyncString
|
||||||
|
import com.nuvio.app.core.sync.encodeSyncBoolean
|
||||||
|
import com.nuvio.app.core.sync.encodeSyncInt
|
||||||
|
import com.nuvio.app.core.sync.encodeSyncString
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
actual object DebridSettingsStorage {
|
||||||
|
private const val preferencesName = "nuvio_debrid_settings"
|
||||||
|
private const val enabledKey = "debrid_enabled"
|
||||||
|
private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled"
|
||||||
|
private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id"
|
||||||
|
private const val torboxApiKeyKey = "debrid_torbox_api_key"
|
||||||
|
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
|
||||||
|
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
|
||||||
|
private const val streamMaxResultsKey = "debrid_stream_max_results"
|
||||||
|
private const val streamSortModeKey = "debrid_stream_sort_mode"
|
||||||
|
private const val streamMinimumQualityKey = "debrid_stream_minimum_quality"
|
||||||
|
private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter"
|
||||||
|
private const val streamHdrFilterKey = "debrid_stream_hdr_filter"
|
||||||
|
private const val streamCodecFilterKey = "debrid_stream_codec_filter"
|
||||||
|
private const val streamPreferencesKey = "debrid_stream_preferences"
|
||||||
|
private const val streamNameTemplateKey = "debrid_stream_name_template"
|
||||||
|
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
|
||||||
|
private fun syncKeys(): List<String> =
|
||||||
|
listOf(
|
||||||
|
enabledKey,
|
||||||
|
cloudLibraryEnabledKey,
|
||||||
|
preferredResolverProviderIdKey,
|
||||||
|
instantPlaybackPreparationLimitKey,
|
||||||
|
streamMaxResultsKey,
|
||||||
|
streamSortModeKey,
|
||||||
|
streamMinimumQualityKey,
|
||||||
|
streamDolbyVisionFilterKey,
|
||||||
|
streamHdrFilterKey,
|
||||||
|
streamCodecFilterKey,
|
||||||
|
streamPreferencesKey,
|
||||||
|
streamNameTemplateKey,
|
||||||
|
streamDescriptionTemplateKey,
|
||||||
|
) + DebridProviders.all().map { providerApiKeyKey(it.id) }
|
||||||
|
|
||||||
|
private var preferences: SharedPreferences? = null
|
||||||
|
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
|
||||||
|
|
||||||
|
actual fun saveEnabled(enabled: Boolean) {
|
||||||
|
saveBoolean(enabledKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey)
|
||||||
|
|
||||||
|
actual fun saveCloudLibraryEnabled(enabled: Boolean) {
|
||||||
|
saveBoolean(cloudLibraryEnabledKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey)
|
||||||
|
|
||||||
|
actual fun savePreferredResolverProviderId(providerId: String) {
|
||||||
|
saveString(preferredResolverProviderIdKey, providerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadProviderApiKey(providerId: String): String? =
|
||||||
|
loadString(providerApiKeyKey(providerId))
|
||||||
|
|
||||||
|
actual fun saveProviderApiKey(providerId: String, apiKey: String) {
|
||||||
|
saveString(providerApiKeyKey(providerId), apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID)
|
||||||
|
|
||||||
|
actual fun saveTorboxApiKey(apiKey: String) {
|
||||||
|
saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID)
|
||||||
|
|
||||||
|
actual fun saveRealDebridApiKey(apiKey: String) {
|
||||||
|
saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
|
||||||
|
|
||||||
|
actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
|
||||||
|
saveInt(instantPlaybackPreparationLimitKey, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey)
|
||||||
|
|
||||||
|
actual fun saveStreamMaxResults(maxResults: Int) {
|
||||||
|
saveInt(streamMaxResultsKey, maxResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey)
|
||||||
|
|
||||||
|
actual fun saveStreamSortMode(mode: String) {
|
||||||
|
saveString(streamSortModeKey, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey)
|
||||||
|
|
||||||
|
actual fun saveStreamMinimumQuality(quality: String) {
|
||||||
|
saveString(streamMinimumQualityKey, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey)
|
||||||
|
|
||||||
|
actual fun saveStreamDolbyVisionFilter(filter: String) {
|
||||||
|
saveString(streamDolbyVisionFilterKey, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey)
|
||||||
|
|
||||||
|
actual fun saveStreamHdrFilter(filter: String) {
|
||||||
|
saveString(streamHdrFilterKey, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey)
|
||||||
|
|
||||||
|
actual fun saveStreamCodecFilter(filter: String) {
|
||||||
|
saveString(streamCodecFilterKey, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey)
|
||||||
|
|
||||||
|
actual fun saveStreamPreferences(preferences: String) {
|
||||||
|
saveString(streamPreferencesKey, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey)
|
||||||
|
|
||||||
|
actual fun saveStreamNameTemplate(template: String) {
|
||||||
|
saveString(streamNameTemplateKey, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey)
|
||||||
|
|
||||||
|
actual fun saveStreamDescriptionTemplate(template: String) {
|
||||||
|
saveString(streamDescriptionTemplateKey, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBoolean(key: String): Boolean? =
|
||||||
|
preferences?.let { sharedPreferences ->
|
||||||
|
val scopedKey = ProfileScopedKey.of(key)
|
||||||
|
if (sharedPreferences.contains(scopedKey)) {
|
||||||
|
sharedPreferences.getBoolean(scopedKey, false)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBoolean(key: String, enabled: Boolean) {
|
||||||
|
preferences
|
||||||
|
?.edit()
|
||||||
|
?.putBoolean(ProfileScopedKey.of(key), enabled)
|
||||||
|
?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInt(key: String): Int? =
|
||||||
|
preferences?.let { sharedPreferences ->
|
||||||
|
val scopedKey = ProfileScopedKey.of(key)
|
||||||
|
if (sharedPreferences.contains(scopedKey)) {
|
||||||
|
sharedPreferences.getInt(scopedKey, 0)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveInt(key: String, value: Int) {
|
||||||
|
preferences
|
||||||
|
?.edit()
|
||||||
|
?.putInt(ProfileScopedKey.of(key), value)
|
||||||
|
?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadString(key: String): String? =
|
||||||
|
preferences?.getString(ProfileScopedKey.of(key), null)
|
||||||
|
|
||||||
|
private fun saveString(key: String, value: String) {
|
||||||
|
preferences
|
||||||
|
?.edit()
|
||||||
|
?.putString(ProfileScopedKey.of(key), value)
|
||||||
|
?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||||
|
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
|
||||||
|
loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) }
|
||||||
|
loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) }
|
||||||
|
DebridProviders.all().forEach { provider ->
|
||||||
|
loadProviderApiKey(provider.id)?.let {
|
||||||
|
put(providerApiKeyKey(provider.id), encodeSyncString(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
|
||||||
|
loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
|
||||||
|
loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
|
||||||
|
loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) }
|
||||||
|
loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) }
|
||||||
|
loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) }
|
||||||
|
loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) }
|
||||||
|
loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) }
|
||||||
|
loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) }
|
||||||
|
loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
||||||
|
preferences?.edit()?.apply {
|
||||||
|
syncKeys().forEach { remove(ProfileScopedKey.of(it)) }
|
||||||
|
}?.apply()
|
||||||
|
|
||||||
|
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
|
||||||
|
payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled)
|
||||||
|
payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId)
|
||||||
|
DebridProviders.all().forEach { provider ->
|
||||||
|
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey ->
|
||||||
|
saveProviderApiKey(provider.id, apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
|
||||||
|
payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
|
||||||
|
payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
|
||||||
|
payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality)
|
||||||
|
payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter)
|
||||||
|
payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter)
|
||||||
|
payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter)
|
||||||
|
payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences)
|
||||||
|
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
|
||||||
|
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun providerApiKeyKey(providerId: String): String {
|
||||||
|
val normalized = DebridProviders.byId(providerId)?.id
|
||||||
|
?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_")
|
||||||
|
return when (normalized) {
|
||||||
|
DebridProviders.TORBOX_ID -> torboxApiKeyKey
|
||||||
|
DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey
|
||||||
|
else -> "debrid_${normalized}_api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -379,6 +379,7 @@
|
||||||
<string name="compose_settings_page_appearance">Utseende</string>
|
<string name="compose_settings_page_appearance">Utseende</string>
|
||||||
<string name="compose_settings_page_content_discovery">Innhold & oppdagelse</string>
|
<string name="compose_settings_page_content_discovery">Innhold & oppdagelse</string>
|
||||||
<string name="compose_settings_page_continue_watching">Fortsett å se</string>
|
<string name="compose_settings_page_continue_watching">Fortsett å se</string>
|
||||||
|
<string name="compose_settings_page_debrid">Tilkoblede tjenester</string>
|
||||||
<string name="compose_settings_page_homescreen">Hjemmeoppsett</string>
|
<string name="compose_settings_page_homescreen">Hjemmeoppsett</string>
|
||||||
<string name="compose_settings_page_integrations">Integrasjoner</string>
|
<string name="compose_settings_page_integrations">Integrasjoner</string>
|
||||||
<string name="compose_settings_page_licenses_attributions">Lisenser & attribusjon</string>
|
<string name="compose_settings_page_licenses_attributions">Lisenser & attribusjon</string>
|
||||||
|
|
@ -587,6 +588,48 @@
|
||||||
<string name="settings_integrations_section_title">Integrasjoner</string>
|
<string name="settings_integrations_section_title">Integrasjoner</string>
|
||||||
<string name="settings_integrations_tmdb_description">Metadata-berikelse-kontroller</string>
|
<string name="settings_integrations_tmdb_description">Metadata-berikelse-kontroller</string>
|
||||||
<string name="settings_integrations_mdblist_description">Eksterne vurderingsleverandører</string>
|
<string name="settings_integrations_mdblist_description">Eksterne vurderingsleverandører</string>
|
||||||
|
<string name="settings_integrations_debrid_description">Koble til kontoer for lenker og bibliotektilgang</string>
|
||||||
|
<string name="settings_debrid_section_title">Tilkoblede tjenester</string>
|
||||||
|
<string name="settings_debrid_experimental_notice">Disse integrasjonene er eksperimentelle og kan endres eller fjernes senere.</string>
|
||||||
|
<string name="settings_debrid_cloud_library">Skybibliotek</string>
|
||||||
|
<string name="settings_debrid_cloud_library_description">Bla gjennom og spill filer som allerede finnes i tilkoblede kontoer.</string>
|
||||||
|
<string name="settings_debrid_enable">Løs spillbare lenker</string>
|
||||||
|
<string name="settings_debrid_enable_description">Be en tilkoblet tjeneste om spillbare lenker når et resultat trenger det. Dette kan legge elementet til i den tjenesten.</string>
|
||||||
|
<string name="settings_debrid_resolve_with">Løs med</string>
|
||||||
|
<string name="settings_debrid_resolve_with_description">Velg hvilken tilkoblet konto som håndterer spillbare lenker.</string>
|
||||||
|
<string name="settings_debrid_add_key_first">Koble til en konto først.</string>
|
||||||
|
<string name="settings_debrid_section_providers">Kontoer</string>
|
||||||
|
<string name="settings_debrid_provider_description">Koble til %1$s-kontoen din.</string>
|
||||||
|
<string name="settings_debrid_provider_device_description">Koble til %1$s-kontoen din i nettleseren.</string>
|
||||||
|
<string name="settings_debrid_dialog_title">%1$s API-nøkkel</string>
|
||||||
|
<string name="settings_debrid_dialog_subtitle">Skriv inn API-nøkkelen din for %1$s.</string>
|
||||||
|
<string name="settings_debrid_dialog_placeholder">Skriv inn %1$s API-nøkkel</string>
|
||||||
|
<string name="settings_debrid_connected">Tilkoblet</string>
|
||||||
|
<string name="settings_debrid_connect_provider">Koble til %1$s</string>
|
||||||
|
<string name="settings_debrid_disconnect_provider">Koble fra %1$s</string>
|
||||||
|
<string name="settings_debrid_disconnect">Koble fra</string>
|
||||||
|
<string name="settings_debrid_device_auth_connected">%1$s er koblet til på denne enheten.</string>
|
||||||
|
<string name="settings_debrid_device_auth_starting">Starter sikker innlogging...</string>
|
||||||
|
<string name="settings_debrid_device_auth_instructions">Åpne lenken og skriv inn denne koden for å godkjenne Nuvio.</string>
|
||||||
|
<string name="settings_debrid_device_auth_code_copied">Kode kopiert.</string>
|
||||||
|
<string name="settings_debrid_device_auth_open">Åpne lenke</string>
|
||||||
|
<string name="settings_debrid_device_auth_waiting">Venter på godkjenning...</string>
|
||||||
|
<string name="settings_debrid_device_auth_failed">Kunne ikke starte innlogging.</string>
|
||||||
|
<string name="settings_debrid_device_auth_missing_configuration">Denne innloggingsmetoden er ikke konfigurert i denne versjonen.</string>
|
||||||
|
<string name="settings_debrid_device_auth_expired">Denne koden er utløpt. Prøv igjen.</string>
|
||||||
|
<string name="settings_debrid_section_instant_playback">Lenkeforberedelse</string>
|
||||||
|
<string name="settings_debrid_prepare_instant_playback">Forbered lenker</string>
|
||||||
|
<string name="settings_debrid_prepare_instant_playback_description">Løs spillbare lenker før avspilling starter.</string>
|
||||||
|
<string name="settings_debrid_prepare_stream_count">Lenker å forberede</string>
|
||||||
|
<string name="settings_debrid_prepare_count_one">1 lenke</string>
|
||||||
|
<string name="settings_debrid_prepare_count_many">%1$d lenker</string>
|
||||||
|
<string name="settings_debrid_section_formatting">Formatering</string>
|
||||||
|
<string name="settings_debrid_name_template">Navnemal</string>
|
||||||
|
<string name="settings_debrid_name_template_description">Styrer hvordan resultatnavn vises.</string>
|
||||||
|
<string name="settings_debrid_description_template">Beskrivelsesmal</string>
|
||||||
|
<string name="settings_debrid_description_template_description">Styrer metadata vist under hvert resultat.</string>
|
||||||
|
<string name="settings_debrid_key_valid">API-nøkkel validert.</string>
|
||||||
|
<string name="settings_debrid_key_invalid">Kunne ikke validere denne API-nøkkelen.</string>
|
||||||
<string name="settings_mdb_add_api_key_first">Legg til MDBList API-nøkkel før du skrur på vurderinger.</string>
|
<string name="settings_mdb_add_api_key_first">Legg til MDBList API-nøkkel før du skrur på vurderinger.</string>
|
||||||
<string name="settings_mdb_api_key_description">Kreves for å hente vurderinger fra MDBList</string>
|
<string name="settings_mdb_api_key_description">Kreves for å hente vurderinger fra MDBList</string>
|
||||||
<string name="settings_mdb_api_key_label">API-nøkkel</string>
|
<string name="settings_mdb_api_key_label">API-nøkkel</string>
|
||||||
|
|
@ -1122,6 +1165,9 @@
|
||||||
<string name="streams_resume_from_time">Gjenoppta fra %1$s</string>
|
<string name="streams_resume_from_time">Gjenoppta fra %1$s</string>
|
||||||
<string name="streams_size">STØRRELSE %1$s</string>
|
<string name="streams_size">STØRRELSE %1$s</string>
|
||||||
<string name="streams_torrent_not_supported">Denne strømtypen støttes ikke</string>
|
<string name="streams_torrent_not_supported">Denne strømtypen støttes ikke</string>
|
||||||
|
<string name="debrid_missing_api_key">Koble til en konto i Innstillinger.</string>
|
||||||
|
<string name="debrid_stream_stale">Denne lenken er utgått. Oppdaterer resultater.</string>
|
||||||
|
<string name="debrid_resolve_failed">Kunne ikke åpne denne lenken.</string>
|
||||||
<string name="external_player_failed">Kunne ikke åpne ekstern avspiller</string>
|
<string name="external_player_failed">Kunne ikke åpne ekstern avspiller</string>
|
||||||
<string name="external_player_not_configured">Velg en ekstern avspiller i innstillinger først</string>
|
<string name="external_player_not_configured">Velg en ekstern avspiller i innstillinger først</string>
|
||||||
<string name="external_player_unavailable">Ingen ekstern avspiller er tilgjengelig</string>
|
<string name="external_player_unavailable">Ingen ekstern avspiller er tilgjengelig</string>
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,7 @@
|
||||||
<string name="compose_settings_page_appearance">Wygląd</string>
|
<string name="compose_settings_page_appearance">Wygląd</string>
|
||||||
<string name="compose_settings_page_content_discovery">Treści i odkrywanie</string>
|
<string name="compose_settings_page_content_discovery">Treści i odkrywanie</string>
|
||||||
<string name="compose_settings_page_continue_watching">Kontynuuj oglądanie</string>
|
<string name="compose_settings_page_continue_watching">Kontynuuj oglądanie</string>
|
||||||
|
<string name="compose_settings_page_debrid">Debrid</string>
|
||||||
<string name="compose_settings_page_homescreen">Ekran główny</string>
|
<string name="compose_settings_page_homescreen">Ekran główny</string>
|
||||||
<string name="compose_settings_page_integrations">Integracje</string>
|
<string name="compose_settings_page_integrations">Integracje</string>
|
||||||
<string name="compose_settings_page_licenses_attributions">Licencje i atrybucje</string>
|
<string name="compose_settings_page_licenses_attributions">Licencje i atrybucje</string>
|
||||||
|
|
@ -588,6 +589,34 @@
|
||||||
<string name="settings_integrations_section_title">INTEGRACJE</string>
|
<string name="settings_integrations_section_title">INTEGRACJE</string>
|
||||||
<string name="settings_integrations_tmdb_description">Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko.</string>
|
<string name="settings_integrations_tmdb_description">Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko.</string>
|
||||||
<string name="settings_integrations_mdblist_description">Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów.</string>
|
<string name="settings_integrations_mdblist_description">Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów.</string>
|
||||||
|
<string name="settings_integrations_debrid_description">Eksperymentalne źródła z kont chmurowych</string>
|
||||||
|
<string name="settings_debrid_section_title">Debrid</string>
|
||||||
|
<string name="settings_debrid_experimental_notice">Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości.</string>
|
||||||
|
<string name="settings_debrid_enable">Włącz źródła</string>
|
||||||
|
<string name="settings_debrid_enable_description">Pokaż odtwarzalne wyniki z połączonych kont.</string>
|
||||||
|
<string name="settings_debrid_add_key_first">Najpierw dodaj klucz API.</string>
|
||||||
|
<string name="settings_debrid_section_providers">Konto</string>
|
||||||
|
<string name="settings_debrid_provider_torbox_description">Połącz swoje konto Torbox.</string>
|
||||||
|
<string name="settings_debrid_dialog_title">Klucz API Torbox</string>
|
||||||
|
<string name="settings_debrid_dialog_subtitle">Wprowadź swój klucz API Torbox.</string>
|
||||||
|
<string name="settings_debrid_dialog_placeholder">Wprowadź klucz API Torbox</string>
|
||||||
|
<string name="settings_debrid_not_set">Nie ustawiono</string>
|
||||||
|
<string name="settings_debrid_section_instant_playback">Natychmiastowe odtwarzanie</string>
|
||||||
|
<string name="settings_debrid_prepare_instant_playback">Przygotuj linki</string>
|
||||||
|
<string name="settings_debrid_prepare_instant_playback_description">Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania.</string>
|
||||||
|
<string name="settings_debrid_prepare_stream_count">Źródła do przygotowania</string>
|
||||||
|
<string name="settings_debrid_prepare_stream_count_warning">Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem.</string>
|
||||||
|
<string name="settings_debrid_prepare_count_one">1 źródło</string>
|
||||||
|
<string name="settings_debrid_prepare_count_many">%1$d źródeł</string>
|
||||||
|
<string name="settings_debrid_section_formatting">Formatowanie</string>
|
||||||
|
<string name="settings_debrid_name_template">Szablon nazwy</string>
|
||||||
|
<string name="settings_debrid_name_template_description">Kontroluje sposób wyświetlania nazw źródeł.</string>
|
||||||
|
<string name="settings_debrid_description_template">Szablon opisu</string>
|
||||||
|
<string name="settings_debrid_description_template_description">Kontroluje metadane wyświetlane pod każdym źródłem.</string>
|
||||||
|
<string name="settings_debrid_formatter_reset_title">Resetuj formatowanie</string>
|
||||||
|
<string name="settings_debrid_formatter_reset_subtitle">Przywróć domyślne formatowanie źródeł.</string>
|
||||||
|
<string name="settings_debrid_key_valid">Klucz API zweryfikowany.</string>
|
||||||
|
<string name="settings_debrid_key_invalid">Nie udało się zweryfikować tego klucza API.</string>
|
||||||
<string name="settings_mdb_add_api_key_first">Dodaj klucz API MDBList poniżej przed włączeniem ocen.</string>
|
<string name="settings_mdb_add_api_key_first">Dodaj klucz API MDBList poniżej przed włączeniem ocen.</string>
|
||||||
<string name="settings_mdb_api_key_description">Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj.</string>
|
<string name="settings_mdb_api_key_description">Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj.</string>
|
||||||
<string name="settings_mdb_api_key_label">Klucz API</string>
|
<string name="settings_mdb_api_key_label">Klucz API</string>
|
||||||
|
|
@ -1125,6 +1154,9 @@
|
||||||
<string name="streams_resume_from_time">Wznów od %1$s</string>
|
<string name="streams_resume_from_time">Wznów od %1$s</string>
|
||||||
<string name="streams_size">ROZMIAR %1$s</string>
|
<string name="streams_size">ROZMIAR %1$s</string>
|
||||||
<string name="streams_torrent_not_supported">Ten typ strumienia nie jest obsługiwany</string>
|
<string name="streams_torrent_not_supported">Ten typ strumienia nie jest obsługiwany</string>
|
||||||
|
<string name="debrid_missing_api_key">Dodaj klucz API Debrid w Ustawieniach.</string>
|
||||||
|
<string name="debrid_stream_stale">Ten wynik Debrid wygasł. Odświeżanie strumieni.</string>
|
||||||
|
<string name="debrid_resolve_failed">Nie udało się rozwiązać tego strumienia Debrid.</string>
|
||||||
<string name="external_player_failed">Nie udało się otworzyć zewnętrznego odtwarzacza</string>
|
<string name="external_player_failed">Nie udało się otworzyć zewnętrznego odtwarzacza</string>
|
||||||
<string name="external_player_not_configured">Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach</string>
|
<string name="external_player_not_configured">Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach</string>
|
||||||
<string name="external_player_unavailable">Brak dostępnego zewnętrznego odtwarzacza</string>
|
<string name="external_player_unavailable">Brak dostępnego zewnętrznego odtwarzacza</string>
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,7 @@
|
||||||
<string name="compose_settings_page_appearance">Layout</string>
|
<string name="compose_settings_page_appearance">Layout</string>
|
||||||
<string name="compose_settings_page_content_discovery">Content & Discovery</string>
|
<string name="compose_settings_page_content_discovery">Content & Discovery</string>
|
||||||
<string name="compose_settings_page_continue_watching">Continue Watching</string>
|
<string name="compose_settings_page_continue_watching">Continue Watching</string>
|
||||||
|
<string name="compose_settings_page_debrid">Connected Services</string>
|
||||||
<string name="compose_settings_page_homescreen">Home Layout</string>
|
<string name="compose_settings_page_homescreen">Home Layout</string>
|
||||||
<string name="compose_settings_page_integrations">Integrations</string>
|
<string name="compose_settings_page_integrations">Integrations</string>
|
||||||
<string name="compose_settings_page_licenses_attributions">Licenses & Attribution</string>
|
<string name="compose_settings_page_licenses_attributions">Licenses & Attribution</string>
|
||||||
|
|
@ -588,6 +589,52 @@
|
||||||
<string name="settings_integrations_section_title">Integrations</string>
|
<string name="settings_integrations_section_title">Integrations</string>
|
||||||
<string name="settings_integrations_tmdb_description">Metadata enrichment controls</string>
|
<string name="settings_integrations_tmdb_description">Metadata enrichment controls</string>
|
||||||
<string name="settings_integrations_mdblist_description">External ratings providers</string>
|
<string name="settings_integrations_mdblist_description">External ratings providers</string>
|
||||||
|
<string name="settings_integrations_debrid_description">Connect accounts for links and library access</string>
|
||||||
|
<string name="settings_debrid_section_title">Connected Services</string>
|
||||||
|
<string name="settings_debrid_experimental_notice">These integrations are experimental and may be kept, changed, or removed later.</string>
|
||||||
|
<string name="settings_debrid_cloud_library">Cloud library</string>
|
||||||
|
<string name="settings_debrid_cloud_library_description">Browse and play files already in your connected accounts.</string>
|
||||||
|
<string name="settings_debrid_enable">Resolve playable links</string>
|
||||||
|
<string name="settings_debrid_enable_description">Ask a connected service for playable links when a result needs it. This may add the item to that service.</string>
|
||||||
|
<string name="settings_debrid_resolve_with">Resolve with</string>
|
||||||
|
<string name="settings_debrid_resolve_with_description">Choose which connected account handles playable links.</string>
|
||||||
|
<string name="settings_debrid_add_key_first">Connect an account first.</string>
|
||||||
|
<string name="settings_debrid_section_providers">Accounts</string>
|
||||||
|
<string name="settings_debrid_provider_description">Connect your %1$s account.</string>
|
||||||
|
<string name="settings_debrid_provider_device_description">Link your %1$s account in the browser.</string>
|
||||||
|
<string name="settings_debrid_dialog_title">%1$s API Key</string>
|
||||||
|
<string name="settings_debrid_dialog_subtitle">Enter your %1$s API key.</string>
|
||||||
|
<string name="settings_debrid_dialog_placeholder">Enter %1$s API key</string>
|
||||||
|
<string name="settings_debrid_not_set">Not set</string>
|
||||||
|
<string name="settings_debrid_connected">Connected</string>
|
||||||
|
<string name="settings_debrid_connect_provider">Connect %1$s</string>
|
||||||
|
<string name="settings_debrid_disconnect_provider">Disconnect %1$s</string>
|
||||||
|
<string name="settings_debrid_disconnect">Disconnect</string>
|
||||||
|
<string name="settings_debrid_device_auth_connected">%1$s is connected on this device.</string>
|
||||||
|
<string name="settings_debrid_device_auth_starting">Starting secure sign-in...</string>
|
||||||
|
<string name="settings_debrid_device_auth_instructions">Open the link and enter this code to approve Nuvio.</string>
|
||||||
|
<string name="settings_debrid_device_auth_code_copied">Code copied.</string>
|
||||||
|
<string name="settings_debrid_device_auth_open">Open link</string>
|
||||||
|
<string name="settings_debrid_device_auth_waiting">Waiting for approval...</string>
|
||||||
|
<string name="settings_debrid_device_auth_failed">Could not start sign-in.</string>
|
||||||
|
<string name="settings_debrid_device_auth_missing_configuration">This sign-in method is not configured in this build.</string>
|
||||||
|
<string name="settings_debrid_device_auth_expired">This code expired. Try again.</string>
|
||||||
|
<string name="settings_debrid_section_instant_playback">Link Preparation</string>
|
||||||
|
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
|
||||||
|
<string name="settings_debrid_prepare_instant_playback_description">Resolve playable links before playback starts.</string>
|
||||||
|
<string name="settings_debrid_prepare_stream_count">Links to prepare</string>
|
||||||
|
<string name="settings_debrid_prepare_stream_count_warning">Use a lower count when possible. Connected services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time.</string>
|
||||||
|
<string name="settings_debrid_prepare_count_one">1 link</string>
|
||||||
|
<string name="settings_debrid_prepare_count_many">%1$d links</string>
|
||||||
|
<string name="settings_debrid_section_formatting">Formatting</string>
|
||||||
|
<string name="settings_debrid_name_template">Name template</string>
|
||||||
|
<string name="settings_debrid_name_template_description">Controls how result names appear.</string>
|
||||||
|
<string name="settings_debrid_description_template">Description template</string>
|
||||||
|
<string name="settings_debrid_description_template_description">Controls the metadata shown under each result.</string>
|
||||||
|
<string name="settings_debrid_formatter_reset_title">Reset formatting</string>
|
||||||
|
<string name="settings_debrid_formatter_reset_subtitle">Restore default result formatting.</string>
|
||||||
|
<string name="settings_debrid_key_valid">API key validated.</string>
|
||||||
|
<string name="settings_debrid_key_invalid">Could not validate this API key.</string>
|
||||||
<string name="settings_mdb_add_api_key_first">Add your MDBList API key below before turning ratings on.</string>
|
<string name="settings_mdb_add_api_key_first">Add your MDBList API key below before turning ratings on.</string>
|
||||||
<string name="settings_mdb_api_key_description">Required to fetch ratings from MDBList</string>
|
<string name="settings_mdb_api_key_description">Required to fetch ratings from MDBList</string>
|
||||||
<string name="settings_mdb_api_key_label">API Key</string>
|
<string name="settings_mdb_api_key_label">API Key</string>
|
||||||
|
|
@ -1126,6 +1173,10 @@
|
||||||
<string name="streams_resume_from_time">Resume from %1$s</string>
|
<string name="streams_resume_from_time">Resume from %1$s</string>
|
||||||
<string name="streams_size">SIZE %1$s</string>
|
<string name="streams_size">SIZE %1$s</string>
|
||||||
<string name="streams_torrent_not_supported">This stream type is not supported</string>
|
<string name="streams_torrent_not_supported">This stream type is not supported</string>
|
||||||
|
<string name="debrid_missing_api_key">Connect an account in Settings.</string>
|
||||||
|
<string name="debrid_not_cached">Not cached on Torbox.</string>
|
||||||
|
<string name="debrid_stream_stale">This link expired. Refreshing results.</string>
|
||||||
|
<string name="debrid_resolve_failed">Could not open this link.</string>
|
||||||
<string name="external_player_failed">Couldn't open external player</string>
|
<string name="external_player_failed">Couldn't open external player</string>
|
||||||
<string name="external_player_not_configured">Choose an external player in settings first</string>
|
<string name="external_player_not_configured">Choose an external player in settings first</string>
|
||||||
<string name="external_player_unavailable">No external player is available</string>
|
<string name="external_player_unavailable">No external player is available</string>
|
||||||
|
|
@ -1277,11 +1328,42 @@
|
||||||
<string name="library_empty_title">Your library is empty</string>
|
<string name="library_empty_title">Your library is empty</string>
|
||||||
<string name="library_load_failed">Couldn't load library</string>
|
<string name="library_load_failed">Couldn't load library</string>
|
||||||
<string name="library_other">Other</string>
|
<string name="library_other">Other</string>
|
||||||
|
<string name="library_source_cloud">Cloud</string>
|
||||||
|
<string name="library_source_saved">Saved</string>
|
||||||
<string name="library_title">Library</string>
|
<string name="library_title">Library</string>
|
||||||
<string name="library_trakt_empty_message">Connect Trakt and save titles to your watchlist or personal lists.</string>
|
<string name="library_trakt_empty_message">Connect Trakt and save titles to your watchlist or personal lists.</string>
|
||||||
<string name="library_trakt_empty_title">Your Trakt library is empty</string>
|
<string name="library_trakt_empty_title">Your Trakt library is empty</string>
|
||||||
<string name="library_trakt_load_failed">Couldn't load Trakt library</string>
|
<string name="library_trakt_load_failed">Couldn't load Trakt library</string>
|
||||||
<string name="library_trakt_title">Trakt Library</string>
|
<string name="library_trakt_title">Trakt Library</string>
|
||||||
|
<string name="cloud_library_connect_action">Connect account</string>
|
||||||
|
<string name="cloud_library_connect_message">Connect an account in Connected Services settings to browse playable files from your cloud library.</string>
|
||||||
|
<string name="cloud_library_connect_title">No cloud account connected</string>
|
||||||
|
<string name="cloud_library_disabled_action">Open Connected Services</string>
|
||||||
|
<string name="cloud_library_disabled_message">Turn on Cloud library in Connected Services settings to browse files from connected accounts.</string>
|
||||||
|
<string name="cloud_library_disabled_title">Cloud library is off</string>
|
||||||
|
<string name="cloud_library_empty_message">No playable cloud files match the current filters.</string>
|
||||||
|
<string name="cloud_library_empty_title">Nothing here yet</string>
|
||||||
|
<string name="cloud_library_file_picker_title">Choose a file to play</string>
|
||||||
|
<string name="cloud_library_load_failed">Couldn't load %1$s cloud library</string>
|
||||||
|
<string name="cloud_library_no_files_message">This item does not expose a playable video file.</string>
|
||||||
|
<string name="cloud_library_no_files_title">No playable files</string>
|
||||||
|
<string name="cloud_library_no_playable_files">No playable files</string>
|
||||||
|
<string name="cloud_library_play_disabled">Cloud library is off.</string>
|
||||||
|
<string name="cloud_library_play_failed">Couldn't play this cloud file.</string>
|
||||||
|
<string name="cloud_library_play_file">Play file</string>
|
||||||
|
<string name="cloud_library_play_not_connected">Cloud service is not connected.</string>
|
||||||
|
<string name="cloud_library_play_provider_not_connected">%1$s is not connected.</string>
|
||||||
|
<string name="cloud_library_playable_file_count">%1$d playable files</string>
|
||||||
|
<string name="cloud_library_provider_all">All</string>
|
||||||
|
<string name="cloud_library_refresh">Refresh cloud library</string>
|
||||||
|
<string name="cloud_library_select_provider">Select provider</string>
|
||||||
|
<string name="cloud_library_select_type">Select type</string>
|
||||||
|
<string name="cloud_library_status_ready">Ready to play</string>
|
||||||
|
<string name="cloud_library_type_all">All</string>
|
||||||
|
<string name="cloud_library_type_torrents">Torrents</string>
|
||||||
|
<string name="cloud_library_type_usenet">Usenet</string>
|
||||||
|
<string name="cloud_library_type_web">Web</string>
|
||||||
|
<string name="cloud_library_type_files">Files</string>
|
||||||
<string name="media_anime">Anime</string>
|
<string name="media_anime">Anime</string>
|
||||||
<string name="media_channels">Channels</string>
|
<string name="media_channels">Channels</string>
|
||||||
<string name="media_movies">Movies</string>
|
<string name="media_movies">Movies</string>
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,17 @@ import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.catalog.CatalogRepository
|
import com.nuvio.app.features.catalog.CatalogRepository
|
||||||
import com.nuvio.app.features.catalog.CatalogScreen
|
import com.nuvio.app.features.catalog.CatalogScreen
|
||||||
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
|
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryFile
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryItem
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryPlaybackTargetLookupResult
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryRepository
|
||||||
|
import com.nuvio.app.features.cloud.playbackVideoId
|
||||||
|
import com.nuvio.app.features.cloud.providerPosterUrl
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
|
||||||
|
import com.nuvio.app.features.debrid.toastMessage
|
||||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||||
import com.nuvio.app.features.downloads.DownloadsScreen
|
import com.nuvio.app.features.downloads.DownloadsScreen
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
|
|
@ -177,6 +188,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
|
||||||
import com.nuvio.app.features.watchprogress.ResumePromptRepository
|
import com.nuvio.app.features.watchprogress.ResumePromptRepository
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||||
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
||||||
|
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||||
import com.nuvio.app.features.watching.application.WatchingActions
|
import com.nuvio.app.features.watching.application.WatchingActions
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -562,6 +574,7 @@ private fun MainAppContent(
|
||||||
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||||
var selectedPosterActionTarget by remember { mutableStateOf<PosterActionTarget?>(null) }
|
var selectedPosterActionTarget by remember { mutableStateOf<PosterActionTarget?>(null) }
|
||||||
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
||||||
|
var requestedSettingsPageName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var showLibraryListPicker by remember { mutableStateOf(false) }
|
var showLibraryListPicker by remember { mutableStateOf(false) }
|
||||||
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
|
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
|
||||||
var pickerTitle by remember { mutableStateOf("") }
|
var pickerTitle by remember { mutableStateOf("") }
|
||||||
|
|
@ -598,6 +611,9 @@ private fun MainAppContent(
|
||||||
val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured)
|
val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured)
|
||||||
val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable)
|
val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable)
|
||||||
val externalPlayerFailedText = stringResource(Res.string.external_player_failed)
|
val externalPlayerFailedText = stringResource(Res.string.external_player_failed)
|
||||||
|
val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed)
|
||||||
|
val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled)
|
||||||
|
val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected)
|
||||||
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
|
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
|
||||||
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
||||||
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -833,6 +849,52 @@ private fun MainAppContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun launchCloudLibraryFile(
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
resumePositionMs: Long? = null,
|
||||||
|
resumeProgressFraction: Float? = null,
|
||||||
|
startFromBeginning: Boolean = false,
|
||||||
|
): Boolean {
|
||||||
|
return when (
|
||||||
|
val resolved = CloudLibraryRepository.resolvePlayback(
|
||||||
|
item = item,
|
||||||
|
file = file,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is CloudLibraryPlaybackResult.Success -> {
|
||||||
|
val playbackTitle = resolved.filename
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: file.name.ifBlank { item.name }
|
||||||
|
val playerLaunch = PlayerLaunch(
|
||||||
|
title = playbackTitle,
|
||||||
|
sourceUrl = resolved.url,
|
||||||
|
streamTitle = playbackTitle,
|
||||||
|
streamSubtitle = item.name.takeIf { it != playbackTitle },
|
||||||
|
providerName = item.providerName,
|
||||||
|
providerAddonId = "cloud:${item.providerId}",
|
||||||
|
poster = item.providerPosterUrl(),
|
||||||
|
contentType = CloudLibraryContentType,
|
||||||
|
videoId = item.playbackVideoId(file),
|
||||||
|
parentMetaId = item.stableKey,
|
||||||
|
parentMetaType = CloudLibraryContentType,
|
||||||
|
initialPositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L),
|
||||||
|
initialProgressFraction = if (startFromBeginning) null else resumeProgressFraction,
|
||||||
|
)
|
||||||
|
if (playerSettingsUiState.externalPlayerEnabled) {
|
||||||
|
openExternalPlayback(playerLaunch)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||||
|
navController.navigate(PlayerRoute(launchId = launchId))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun launchPlaybackWithDownloadPreference(
|
fun launchPlaybackWithDownloadPreference(
|
||||||
type: String,
|
type: String,
|
||||||
videoId: String,
|
videoId: String,
|
||||||
|
|
@ -1003,25 +1065,67 @@ private fun MainAppContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
|
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
|
||||||
launchPlaybackWithDownloadPreference(
|
if (item.isCloudLibraryContinueWatchingItem()) {
|
||||||
type = item.parentMetaType,
|
coroutineScope.launch {
|
||||||
videoId = item.videoId,
|
when (
|
||||||
parentMetaId = item.parentMetaId,
|
val lookup = CloudLibraryRepository.findPlaybackTargetForProgressResult(
|
||||||
parentMetaType = item.parentMetaType,
|
contentId = item.parentMetaId,
|
||||||
title = item.title,
|
videoId = item.videoId,
|
||||||
logo = item.logo,
|
)
|
||||||
poster = item.poster,
|
) {
|
||||||
background = item.background,
|
is CloudLibraryPlaybackTargetLookupResult.Found -> {
|
||||||
seasonNumber = item.seasonNumber,
|
val launched = launchCloudLibraryFile(
|
||||||
episodeNumber = item.episodeNumber,
|
item = lookup.target.item,
|
||||||
episodeTitle = item.episodeTitle,
|
file = lookup.target.file,
|
||||||
episodeThumbnail = item.episodeThumbnail,
|
resumePositionMs = item.resumePositionMs,
|
||||||
pauseDescription = item.pauseDescription,
|
resumeProgressFraction = item.resumeProgressFraction,
|
||||||
resumePositionMs = item.resumePositionMs,
|
startFromBeginning = startFromBeginning,
|
||||||
resumeProgressFraction = item.resumeProgressFraction,
|
)
|
||||||
manualSelection = manualSelection,
|
if (!launched) {
|
||||||
startFromBeginning = startFromBeginning,
|
NuvioToastController.show(cloudLibraryPlayFailedText)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CloudLibraryPlaybackTargetLookupResult.Disabled -> {
|
||||||
|
NuvioToastController.show(cloudLibraryPlayDisabledText)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CloudLibraryPlaybackTargetLookupResult.NotConnected -> {
|
||||||
|
val providerName = lookup.providerName?.takeIf { it.isNotBlank() }
|
||||||
|
NuvioToastController.show(
|
||||||
|
providerName?.let { name ->
|
||||||
|
getString(Res.string.cloud_library_play_provider_not_connected, name)
|
||||||
|
}
|
||||||
|
?: cloudLibraryPlayNotConnectedText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CloudLibraryPlaybackTargetLookupResult.NotFound -> {
|
||||||
|
NuvioToastController.show(cloudLibraryPlayFailedText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
launchPlaybackWithDownloadPreference(
|
||||||
|
type = item.parentMetaType,
|
||||||
|
videoId = item.videoId,
|
||||||
|
parentMetaId = item.parentMetaId,
|
||||||
|
parentMetaType = item.parentMetaType,
|
||||||
|
title = item.title,
|
||||||
|
logo = item.logo,
|
||||||
|
poster = item.poster,
|
||||||
|
background = item.background,
|
||||||
|
seasonNumber = item.seasonNumber,
|
||||||
|
episodeNumber = item.episodeNumber,
|
||||||
|
episodeTitle = item.episodeTitle,
|
||||||
|
episodeThumbnail = item.episodeThumbnail,
|
||||||
|
pauseDescription = item.pauseDescription,
|
||||||
|
resumePositionMs = item.resumePositionMs,
|
||||||
|
resumeProgressFraction = item.resumeProgressFraction,
|
||||||
|
manualSelection = manualSelection,
|
||||||
|
startFromBeginning = startFromBeginning,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item ->
|
val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item ->
|
||||||
|
|
@ -1154,6 +1258,28 @@ private fun MainAppContent(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
||||||
|
onCloudFilePlay = { item, file ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
val resumeItem = WatchProgressRepository
|
||||||
|
.progressForVideo(item.playbackVideoId(file))
|
||||||
|
?.takeIf { it.isResumable }
|
||||||
|
?.toContinueWatchingItem()
|
||||||
|
if (
|
||||||
|
!launchCloudLibraryFile(
|
||||||
|
item = item,
|
||||||
|
file = file,
|
||||||
|
resumePositionMs = resumeItem?.resumePositionMs,
|
||||||
|
resumeProgressFraction = resumeItem?.resumeProgressFraction,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NuvioToastController.show(cloudLibraryPlayFailedText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConnectCloudClick = {
|
||||||
|
requestedSettingsPageName = "Debrid"
|
||||||
|
selectedTab = AppScreenTab.Settings
|
||||||
|
},
|
||||||
onContinueWatchingClick = onContinueWatchingClick,
|
onContinueWatchingClick = onContinueWatchingClick,
|
||||||
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
||||||
onSwitchProfile = onSwitchProfile,
|
onSwitchProfile = onSwitchProfile,
|
||||||
|
|
@ -1188,6 +1314,10 @@ private fun MainAppContent(
|
||||||
onFolderClick = { collectionId, folderId ->
|
onFolderClick = { collectionId, folderId ->
|
||||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||||
},
|
},
|
||||||
|
requestedSettingsPageName = requestedSettingsPageName,
|
||||||
|
onRequestedSettingsPageConsumed = {
|
||||||
|
requestedSettingsPageName = null
|
||||||
|
},
|
||||||
onInitialHomeContentRendered = { initialHomeReady = true },
|
onInitialHomeContentRendered = { initialHomeReady = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1357,6 +1487,8 @@ private fun MainAppContent(
|
||||||
return@composable
|
return@composable
|
||||||
}
|
}
|
||||||
val pauseDescription = launch.pauseDescription
|
val pauseDescription = launch.pauseDescription
|
||||||
|
val streamRouteScope = rememberCoroutineScope()
|
||||||
|
var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) }
|
||||||
val lifecycleOwner = backStackEntry
|
val lifecycleOwner = backStackEntry
|
||||||
DisposableEffect(lifecycleOwner, route.launchId) {
|
DisposableEffect(lifecycleOwner, route.launchId) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
|
@ -1506,8 +1638,42 @@ private fun MainAppContent(
|
||||||
if (reuseNavigated) return@LaunchedEffect
|
if (reuseNavigated) return@LaunchedEffect
|
||||||
if (autoPlayHandled) return@LaunchedEffect
|
if (autoPlayHandled) return@LaunchedEffect
|
||||||
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
|
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
|
||||||
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
||||||
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
|
val stream = if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(selectedStream)) {
|
||||||
|
when (
|
||||||
|
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
|
||||||
|
stream = selectedStream,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DirectDebridPlayableResult.Success -> resolved.stream
|
||||||
|
else -> {
|
||||||
|
val hasNextCandidate = StreamsRepository.skipAutoPlayStream(selectedStream)
|
||||||
|
if (!hasNextCandidate) {
|
||||||
|
resolved.toastMessage()?.let { NuvioToastController.show(it) }
|
||||||
|
}
|
||||||
|
if (!hasNextCandidate && resolved == DirectDebridPlayableResult.Stale) {
|
||||||
|
StreamsRepository.reload(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
parentMetaId = launch.parentMetaId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
manualSelection = launch.manualSelection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedStream
|
||||||
|
}
|
||||||
|
val sourceUrl = stream.playableDirectUrl
|
||||||
|
if (sourceUrl == null) {
|
||||||
|
StreamsRepository.skipAutoPlayStream(selectedStream)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
autoPlayHandled = true
|
autoPlayHandled = true
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
|
|
@ -1584,7 +1750,42 @@ private fun MainAppContent(
|
||||||
forceExternal: Boolean,
|
forceExternal: Boolean,
|
||||||
forceInternal: Boolean,
|
forceInternal: Boolean,
|
||||||
) {
|
) {
|
||||||
val sourceUrl = stream.directPlaybackUrl ?: return
|
if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) {
|
||||||
|
if (resolvingDebridStream) return
|
||||||
|
streamRouteScope.launch {
|
||||||
|
resolvingDebridStream = true
|
||||||
|
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
|
||||||
|
stream = stream,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
)
|
||||||
|
resolvingDebridStream = false
|
||||||
|
when (resolved) {
|
||||||
|
is DirectDebridPlayableResult.Success -> openSelectedStream(
|
||||||
|
stream = resolved.stream,
|
||||||
|
resolvedResumePositionMs = resolvedResumePositionMs,
|
||||||
|
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
|
||||||
|
forceExternal = forceExternal,
|
||||||
|
forceInternal = forceInternal,
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
resolved.toastMessage()?.let { NuvioToastController.show(it) }
|
||||||
|
if (resolved == DirectDebridPlayableResult.Stale) {
|
||||||
|
StreamsRepository.reload(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
parentMetaId = launch.parentMetaId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
manualSelection = launch.manualSelection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val sourceUrl = stream.playableDirectUrl ?: return
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
type = launch.type,
|
type = launch.type,
|
||||||
|
|
@ -1687,6 +1888,26 @@ private fun MainAppContent(
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
if (resolvingDebridStream) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.82f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.streams_finding_source),
|
||||||
|
color = Color.White.copy(alpha = 0.82f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<PlayerRoute>(
|
composable<PlayerRoute>(
|
||||||
|
|
@ -2020,6 +2241,7 @@ private fun MainAppContent(
|
||||||
NuvioContinueWatchingActionSheet(
|
NuvioContinueWatchingActionSheet(
|
||||||
item = selectedContinueWatchingForActions,
|
item = selectedContinueWatchingForActions,
|
||||||
showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState),
|
showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState),
|
||||||
|
showDetailsOption = selectedContinueWatchingForActions?.isCloudLibraryContinueWatchingItem() != true,
|
||||||
onDismiss = { selectedContinueWatchingForActions = null },
|
onDismiss = { selectedContinueWatchingForActions = null },
|
||||||
onOpenDetails = {
|
onOpenDetails = {
|
||||||
selectedContinueWatchingForActions?.let { item ->
|
selectedContinueWatchingForActions?.let { item ->
|
||||||
|
|
@ -2196,6 +2418,8 @@ private fun AppTabHost(
|
||||||
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
|
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
|
||||||
onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
|
onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
|
||||||
onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null,
|
onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null,
|
||||||
|
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
|
||||||
|
onConnectCloudClick: (() -> Unit)? = null,
|
||||||
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
|
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
|
||||||
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
|
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
|
||||||
onSwitchProfile: (() -> Unit)? = null,
|
onSwitchProfile: (() -> Unit)? = null,
|
||||||
|
|
@ -2211,6 +2435,8 @@ private fun AppTabHost(
|
||||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsSettingsClick: () -> Unit = {},
|
onCollectionsSettingsClick: () -> Unit = {},
|
||||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||||
|
requestedSettingsPageName: String? = null,
|
||||||
|
onRequestedSettingsPageConsumed: () -> Unit = {},
|
||||||
onInitialHomeContentRendered: () -> Unit = {},
|
onInitialHomeContentRendered: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val tabStateHolder = rememberSaveableStateHolder()
|
val tabStateHolder = rememberSaveableStateHolder()
|
||||||
|
|
@ -2250,6 +2476,8 @@ private fun AppTabHost(
|
||||||
onPosterClick = onLibraryPosterClick,
|
onPosterClick = onLibraryPosterClick,
|
||||||
onPosterLongClick = onLibraryPosterLongClick,
|
onPosterLongClick = onLibraryPosterLongClick,
|
||||||
onSectionViewAllClick = onLibrarySectionViewAllClick,
|
onSectionViewAllClick = onLibrarySectionViewAllClick,
|
||||||
|
onCloudFilePlay = onCloudFilePlay,
|
||||||
|
onConnectCloudClick = onConnectCloudClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2257,6 +2485,8 @@ private fun AppTabHost(
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
rootActionRequests = settingsRootActionRequests,
|
rootActionRequests = settingsRootActionRequests,
|
||||||
|
requestedPageName = requestedSettingsPageName,
|
||||||
|
onRequestedPageConsumed = onRequestedSettingsPageConsumed,
|
||||||
rootActionsEnabled = rootActionsEnabled,
|
rootActionsEnabled = rootActionsEnabled,
|
||||||
onSwitchProfile = onSwitchProfile,
|
onSwitchProfile = onSwitchProfile,
|
||||||
onHomescreenClick = onHomescreenSettingsClick,
|
onHomescreenClick = onHomescreenSettingsClick,
|
||||||
|
|
@ -2391,6 +2621,9 @@ private fun TabletFloatingTopBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean =
|
||||||
|
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TabletTopPillItem(
|
private fun TabletTopPillItem(
|
||||||
label: String,
|
label: String,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
import com.nuvio.app.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsStorage
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||||
|
|
@ -157,6 +159,7 @@ object ProfileSettingsSync {
|
||||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||||
PlayerSettingsRepository.uiState.map { "player" },
|
PlayerSettingsRepository.uiState.map { "player" },
|
||||||
|
DebridSettingsRepository.uiState.map { "debrid" },
|
||||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||||
|
|
@ -202,6 +205,7 @@ object ProfileSettingsSync {
|
||||||
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
|
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
|
||||||
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
|
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
|
||||||
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
|
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
|
||||||
|
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
|
||||||
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
||||||
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
||||||
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
|
|
@ -226,6 +230,9 @@ object ProfileSettingsSync {
|
||||||
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
||||||
PlayerSettingsRepository.onProfileChanged()
|
PlayerSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
|
DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings)
|
||||||
|
DebridSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings)
|
TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings)
|
||||||
TmdbSettingsRepository.onProfileChanged()
|
TmdbSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
|
|
@ -255,6 +262,7 @@ object ProfileSettingsSync {
|
||||||
ThemeSettingsRepository.ensureLoaded()
|
ThemeSettingsRepository.ensureLoaded()
|
||||||
PosterCardStyleRepository.ensureLoaded()
|
PosterCardStyleRepository.ensureLoaded()
|
||||||
PlayerSettingsRepository.ensureLoaded()
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
TmdbSettingsRepository.ensureLoaded()
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
MdbListSettingsRepository.ensureLoaded()
|
MdbListSettingsRepository.ensureLoaded()
|
||||||
MetaScreenSettingsRepository.ensureLoaded()
|
MetaScreenSettingsRepository.ensureLoaded()
|
||||||
|
|
@ -277,6 +285,7 @@ object ProfileSettingsSync {
|
||||||
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||||
"player=${PlayerSettingsRepository.uiState.value}",
|
"player=${PlayerSettingsRepository.uiState.value}",
|
||||||
|
"debrid=${DebridSettingsRepository.uiState.value}",
|
||||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||||
|
|
@ -299,6 +308,7 @@ private data class MobileProfileSettingsFeatures(
|
||||||
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
|
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
|
||||||
@SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
|
@SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.core.ui
|
package com.nuvio.app.core.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
@ -18,6 +19,8 @@ fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem): String {
|
||||||
stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
|
stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
|
||||||
item.isNextUp ->
|
item.isNextUp ->
|
||||||
stringResource(Res.string.continue_watching_up_next)
|
stringResource(Res.string.continue_watching_up_next)
|
||||||
|
item.parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) ->
|
||||||
|
stringResource(Res.string.library_source_cloud)
|
||||||
else ->
|
else ->
|
||||||
stringResource(Res.string.media_movie)
|
stringResource(Res.string.media_movie)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||||
|
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
|
@ -42,6 +44,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||||
fun NuvioContinueWatchingActionSheet(
|
fun NuvioContinueWatchingActionSheet(
|
||||||
item: ContinueWatchingItem?,
|
item: ContinueWatchingItem?,
|
||||||
showManualPlayOption: Boolean,
|
showManualPlayOption: Boolean,
|
||||||
|
showDetailsOption: Boolean = true,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onOpenDetails: () -> Unit,
|
onOpenDetails: () -> Unit,
|
||||||
onStartFromBeginning: (() -> Unit)? = null,
|
onStartFromBeginning: (() -> Unit)? = null,
|
||||||
|
|
@ -73,12 +76,14 @@ fun NuvioContinueWatchingActionSheet(
|
||||||
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
|
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
|
||||||
) {
|
) {
|
||||||
ContinueWatchingSheetHeader(item = item)
|
ContinueWatchingSheetHeader(item = item)
|
||||||
NuvioBottomSheetDivider()
|
if (showDetailsOption) {
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetDivider()
|
||||||
icon = Icons.Default.Info,
|
NuvioBottomSheetActionRow(
|
||||||
title = stringResource(Res.string.cw_action_go_to_details),
|
icon = Icons.Default.Info,
|
||||||
onClick = { dismissAfter(onOpenDetails) },
|
title = stringResource(Res.string.cw_action_go_to_details),
|
||||||
)
|
onClick = { dismissAfter(onOpenDetails) },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showManualPlayOption && onPlayManually != null) {
|
if (showManualPlayOption && onPlayManually != null) {
|
||||||
NuvioBottomSheetDivider()
|
NuvioBottomSheetDivider()
|
||||||
NuvioBottomSheetActionRow(
|
NuvioBottomSheetActionRow(
|
||||||
|
|
@ -128,10 +133,10 @@ private fun ContinueWatchingSheetHeader(
|
||||||
val artwork = item.poster ?: item.imageUrl
|
val artwork = item.poster ?: item.imageUrl
|
||||||
if (artwork != null) {
|
if (artwork != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = artwork,
|
model = cloudLibraryDisplayArtworkUrl(artwork),
|
||||||
contentDescription = item.title,
|
contentDescription = item.title,
|
||||||
modifier = Modifier.matchParentSize(),
|
modifier = Modifier.matchParentSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -167,3 +172,6 @@ private fun ContinueWatchingSheetHeader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean =
|
||||||
|
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
package com.nuvio.app.core.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class NuvioDropdownOption(
|
||||||
|
val key: String,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NuvioDropdownChip(
|
||||||
|
title: String,
|
||||||
|
label: String,
|
||||||
|
selectedKey: String?,
|
||||||
|
options: List<NuvioDropdownOption>,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onSelected: (NuvioDropdownOption) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var isSheetVisible by remember { mutableStateOf(false) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.then(
|
||||||
|
if (enabled) {
|
||||||
|
Modifier.clickable { isSheetVisible = true }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.KeyboardArrowDown,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSheetVisible) {
|
||||||
|
NuvioDropdownOptionsSheet(
|
||||||
|
title = title,
|
||||||
|
options = options,
|
||||||
|
selectedKey = selectedKey,
|
||||||
|
sheetState = sheetState,
|
||||||
|
onDismiss = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
dismissNuvioBottomSheet(
|
||||||
|
sheetState = sheetState,
|
||||||
|
onDismiss = { isSheetVisible = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelected = { option ->
|
||||||
|
onSelected(option)
|
||||||
|
coroutineScope.launch {
|
||||||
|
dismissNuvioBottomSheet(
|
||||||
|
sheetState = sheetState,
|
||||||
|
onDismiss = { isSheetVisible = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun NuvioDropdownOptionsSheet(
|
||||||
|
title: String,
|
||||||
|
options: List<NuvioDropdownOption>,
|
||||||
|
selectedKey: String?,
|
||||||
|
sheetState: SheetState,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelected: (NuvioDropdownOption) -> Unit,
|
||||||
|
) {
|
||||||
|
NuvioModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
NuvioBottomSheetDivider()
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 420.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(options) { index, option ->
|
||||||
|
NuvioBottomSheetActionRow(
|
||||||
|
title = option.label,
|
||||||
|
onClick = { onSelected(option) },
|
||||||
|
trailingContent = {
|
||||||
|
if (option.key == selectedKey) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (index < options.lastIndex) {
|
||||||
|
NuvioBottomSheetDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
package com.nuvio.app.features.cloud
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridProvider
|
||||||
|
|
||||||
|
enum class CloudLibraryItemType {
|
||||||
|
Torrent,
|
||||||
|
Usenet,
|
||||||
|
WebDownload,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CloudLibraryFile(
|
||||||
|
val id: String?,
|
||||||
|
val name: String,
|
||||||
|
val sizeBytes: Long? = null,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val playable: Boolean = true,
|
||||||
|
val playbackUrl: String? = null,
|
||||||
|
) {
|
||||||
|
val stableKey: String
|
||||||
|
get() = id ?: name
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CloudLibraryItem(
|
||||||
|
val providerId: String,
|
||||||
|
val providerName: String,
|
||||||
|
val id: String,
|
||||||
|
val type: CloudLibraryItemType,
|
||||||
|
val name: String,
|
||||||
|
val status: String? = null,
|
||||||
|
val sizeBytes: Long? = null,
|
||||||
|
val progressFraction: Float? = null,
|
||||||
|
val files: List<CloudLibraryFile> = emptyList(),
|
||||||
|
) {
|
||||||
|
val stableKey: String
|
||||||
|
get() = "$providerId:${type.name}:$id"
|
||||||
|
|
||||||
|
val playableFiles: List<CloudLibraryFile>
|
||||||
|
get() = files.filter { it.playable }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CloudLibraryPlaybackTarget(
|
||||||
|
val item: CloudLibraryItem,
|
||||||
|
val file: CloudLibraryFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface CloudLibraryPlaybackTargetLookupResult {
|
||||||
|
data class Found(val target: CloudLibraryPlaybackTarget) : CloudLibraryPlaybackTargetLookupResult
|
||||||
|
data object Disabled : CloudLibraryPlaybackTargetLookupResult
|
||||||
|
data class NotConnected(val providerName: String? = null) : CloudLibraryPlaybackTargetLookupResult
|
||||||
|
data object NotFound : CloudLibraryPlaybackTargetLookupResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const val CloudLibraryContentType = "cloud"
|
||||||
|
const val TorboxCloudLibraryPosterUrl = "https://torbox.app/assets/logo-bb7a9579.svg"
|
||||||
|
const val PremiumizeCloudLibraryPosterUrl = "https://www.premiumize.me/icon_normal.svg"
|
||||||
|
private const val TorboxCloudLibraryPosterDataUrl =
|
||||||
|
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjM2NyAzMDggNzY2IDg4NCI+PHBvbHlnb24gZmlsbD0iIzAwNDQ0RCIgcG9pbnRzPSI3NDkuOTksNzQ5Ljk5IDc0OS45OSwxMTkxLjk2IDM2Ny4yNSw5NzAuOTcgMzY3LjI1LDUyOS4wMSIvPjxwb2x5Z29uIGZpbGw9IiMzNEJBOTAiIHBvaW50cz0iMTEzMi43NSw1MjkuMDEgMTEzMi43NSw5NzAuOTcgNzQ5Ljk5LDExOTEuOTYgNzQ5Ljk5LDc0OS45OSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYiLz48cG9seWdvbiBmaWxsPSIjNTJBMTUzIiBwb2ludHM9IjExMzIuNzUsNTI5LjAxIDc0OS45OSw3NDkuOTkgMzY3LjI1LDUyOS4wMSA3NDkuOTksMzA4LjA0Ii8+PHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSIxMDQzLjA0LDczOS4zNiA5NTguNjYsMTA1Ny4wOCA5NTIuNCw4NTEuODQgODM5LjcxLDkxNS4zOSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYgOTMxLjgxLDc5OS4yMSIvPjwvc3ZnPg=="
|
||||||
|
|
||||||
|
fun CloudLibraryItem.playbackVideoId(file: CloudLibraryFile): String =
|
||||||
|
"$stableKey:${file.stableKey}"
|
||||||
|
|
||||||
|
fun CloudLibraryItem.providerPosterUrl(): String? =
|
||||||
|
cloudLibraryProviderPosterUrl(providerId)
|
||||||
|
|
||||||
|
fun cloudLibraryProviderPosterUrl(providerIdOrContentId: String?): String? =
|
||||||
|
when (cloudLibraryProviderId(providerIdOrContentId)) {
|
||||||
|
"torbox" -> TorboxCloudLibraryPosterUrl
|
||||||
|
"premiumize" -> PremiumizeCloudLibraryPosterUrl
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cloudLibraryDisplayArtworkUrl(url: String?): String? =
|
||||||
|
when (url?.trim()) {
|
||||||
|
TorboxCloudLibraryPosterUrl -> TorboxCloudLibraryPosterDataUrl
|
||||||
|
else -> url?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cloudLibraryProviderId(providerIdOrContentId: String?): String =
|
||||||
|
providerIdOrContentId.orEmpty()
|
||||||
|
.trim()
|
||||||
|
.removePrefix("$CloudLibraryContentType:")
|
||||||
|
.substringBefore(':')
|
||||||
|
.lowercase()
|
||||||
|
|
||||||
|
data class CloudLibraryProviderState(
|
||||||
|
val provider: DebridProvider,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val items: List<CloudLibraryItem> = emptyList(),
|
||||||
|
) {
|
||||||
|
val providerId: String
|
||||||
|
get() = provider.id
|
||||||
|
|
||||||
|
val providerName: String
|
||||||
|
get() = provider.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CloudLibraryUiState(
|
||||||
|
val isLoaded: Boolean = false,
|
||||||
|
val isEnabled: Boolean = true,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val providers: List<CloudLibraryProviderState> = emptyList(),
|
||||||
|
) {
|
||||||
|
val items: List<CloudLibraryItem>
|
||||||
|
get() = providers.flatMap { it.items }
|
||||||
|
|
||||||
|
val hasConnectedProvider: Boolean
|
||||||
|
get() = providers.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CloudLibraryPlaybackResult {
|
||||||
|
data class Success(
|
||||||
|
val url: String,
|
||||||
|
val filename: String? = null,
|
||||||
|
val videoSizeBytes: Long? = null,
|
||||||
|
) : CloudLibraryPlaybackResult
|
||||||
|
|
||||||
|
data object MissingCredentials : CloudLibraryPlaybackResult
|
||||||
|
data object NotPlayable : CloudLibraryPlaybackResult
|
||||||
|
data class Failed(val message: String? = null) : CloudLibraryPlaybackResult
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.nuvio.app.features.cloud
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridProvider
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviders
|
||||||
|
|
||||||
|
internal interface CloudLibraryProviderApi {
|
||||||
|
val provider: DebridProvider
|
||||||
|
|
||||||
|
suspend fun listItems(apiKey: String): Result<List<CloudLibraryItem>>
|
||||||
|
|
||||||
|
suspend fun resolvePlayback(
|
||||||
|
apiKey: String,
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
): CloudLibraryPlaybackResult
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object CloudLibraryProviderApis {
|
||||||
|
private val registered = listOf(
|
||||||
|
TorboxCloudLibraryProviderApi(),
|
||||||
|
PremiumizeCloudLibraryProviderApi(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun all(): List<CloudLibraryProviderApi> = registered
|
||||||
|
|
||||||
|
fun apiFor(providerId: String?): CloudLibraryProviderApi? {
|
||||||
|
val normalized = DebridProviders.byId(providerId)?.id ?: return null
|
||||||
|
return registered.firstOrNull { it.provider.id == normalized }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
package com.nuvio.app.features.cloud
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviderCapability
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviders
|
||||||
|
import com.nuvio.app.features.debrid.DebridServiceCredential
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
|
import com.nuvio.app.features.debrid.supports
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
internal class CloudLibraryStore(
|
||||||
|
private val credentialsProvider: suspend () -> List<DebridServiceCredential>,
|
||||||
|
private val providerApis: List<CloudLibraryProviderApi>,
|
||||||
|
) {
|
||||||
|
suspend fun refresh(): CloudLibraryUiState {
|
||||||
|
val credentials = credentialsProvider()
|
||||||
|
.filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
|
||||||
|
|
||||||
|
val providerStates = credentials.map { credential ->
|
||||||
|
val api = providerApis.firstOrNull { it.provider.id == credential.provider.id }
|
||||||
|
if (api == null) {
|
||||||
|
return@map CloudLibraryProviderState(
|
||||||
|
provider = credential.provider,
|
||||||
|
errorMessage = "Cloud library is not available for ${credential.provider.displayName}.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
api.listItems(credential.apiKey)
|
||||||
|
.fold(
|
||||||
|
onSuccess = { items ->
|
||||||
|
CloudLibraryProviderState(
|
||||||
|
provider = credential.provider,
|
||||||
|
items = items,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
CloudLibraryProviderState(
|
||||||
|
provider = credential.provider,
|
||||||
|
errorMessage = error.message,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CloudLibraryUiState(
|
||||||
|
isLoaded = true,
|
||||||
|
isRefreshing = false,
|
||||||
|
providers = providerStates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolvePlayback(
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
): CloudLibraryPlaybackResult {
|
||||||
|
if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable
|
||||||
|
val credential = credentialsProvider()
|
||||||
|
.firstOrNull { credential -> credential.provider.id == item.providerId }
|
||||||
|
?: return CloudLibraryPlaybackResult.MissingCredentials
|
||||||
|
val api = providerApis.firstOrNull { it.provider.id == item.providerId }
|
||||||
|
?: return CloudLibraryPlaybackResult.Failed()
|
||||||
|
file.playbackUrl?.takeIf { it.isNotBlank() }?.let { url ->
|
||||||
|
return CloudLibraryPlaybackResult.Success(
|
||||||
|
url = url,
|
||||||
|
filename = file.name.takeIf { it.isNotBlank() },
|
||||||
|
videoSizeBytes = file.sizeBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return api.resolvePlayback(
|
||||||
|
apiKey = credential.apiKey,
|
||||||
|
item = item,
|
||||||
|
file = file,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object CloudLibraryRepository {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val store = CloudLibraryStore(
|
||||||
|
credentialsProvider = {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
DebridProviders.configuredServices(DebridSettingsRepository.snapshot())
|
||||||
|
},
|
||||||
|
providerApis = CloudLibraryProviderApis.all(),
|
||||||
|
)
|
||||||
|
private val _uiState = MutableStateFlow(CloudLibraryUiState())
|
||||||
|
private var loadedConnectionKeys: List<CloudConnectionKey> = emptyList()
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun ensureLoaded() {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
|
||||||
|
loadedConnectionKeys = emptyList()
|
||||||
|
_uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val current = _uiState.value
|
||||||
|
if (current.isRefreshing) return
|
||||||
|
val connectedKeys = connectedCloudConnectionKeys()
|
||||||
|
if (!current.isLoaded || connectedKeys != loadedConnectionKeys) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
|
||||||
|
loadedConnectionKeys = emptyList()
|
||||||
|
_uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
isEnabled = true,
|
||||||
|
isRefreshing = true,
|
||||||
|
providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
val refreshed = store.refresh()
|
||||||
|
loadedConnectionKeys = connectedCloudConnectionKeys()
|
||||||
|
_uiState.value = refreshed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findPlaybackTargetForProgress(
|
||||||
|
contentId: String,
|
||||||
|
videoId: String,
|
||||||
|
): CloudLibraryPlaybackTarget? =
|
||||||
|
when (val result = findPlaybackTargetForProgressResult(contentId = contentId, videoId = videoId)) {
|
||||||
|
is CloudLibraryPlaybackTargetLookupResult.Found -> result.target
|
||||||
|
CloudLibraryPlaybackTargetLookupResult.Disabled,
|
||||||
|
is CloudLibraryPlaybackTargetLookupResult.NotConnected,
|
||||||
|
CloudLibraryPlaybackTargetLookupResult.NotFound,
|
||||||
|
-> null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findPlaybackTargetForProgressResult(
|
||||||
|
contentId: String,
|
||||||
|
videoId: String,
|
||||||
|
): CloudLibraryPlaybackTargetLookupResult {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
|
||||||
|
loadedConnectionKeys = emptyList()
|
||||||
|
_uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false)
|
||||||
|
return CloudLibraryPlaybackTargetLookupResult.Disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
val providerId = cloudLibraryProviderId(contentId)
|
||||||
|
.ifBlank { cloudLibraryProviderId(videoId) }
|
||||||
|
val connectedCredentials = connectedCloudCredentials()
|
||||||
|
if (connectedCredentials.isEmpty()) {
|
||||||
|
return CloudLibraryPlaybackTargetLookupResult.NotConnected(
|
||||||
|
providerName = providerId.takeIf { it.isNotBlank() }?.let(DebridProviders::displayName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
providerId.isNotBlank() &&
|
||||||
|
connectedCredentials.none { credential -> credential.provider.id.equals(providerId, ignoreCase = true) }
|
||||||
|
) {
|
||||||
|
return CloudLibraryPlaybackTargetLookupResult.NotConnected(
|
||||||
|
providerName = DebridProviders.displayName(providerId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value.findPlaybackTargetForProgress(
|
||||||
|
contentId = contentId,
|
||||||
|
videoId = videoId,
|
||||||
|
)?.let { target -> return CloudLibraryPlaybackTargetLookupResult.Found(target) }
|
||||||
|
|
||||||
|
val refreshed = refreshNow()
|
||||||
|
val refreshedTarget = refreshed.findPlaybackTargetForProgress(
|
||||||
|
contentId = contentId,
|
||||||
|
videoId = videoId,
|
||||||
|
)
|
||||||
|
return if (refreshedTarget != null) {
|
||||||
|
CloudLibraryPlaybackTargetLookupResult.Found(refreshedTarget)
|
||||||
|
} else {
|
||||||
|
CloudLibraryPlaybackTargetLookupResult.NotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolvePlayback(
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
): CloudLibraryPlaybackResult {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
|
||||||
|
return CloudLibraryPlaybackResult.Failed("Cloud library is disabled.")
|
||||||
|
}
|
||||||
|
val result = store.resolvePlayback(item, file)
|
||||||
|
if (result is CloudLibraryPlaybackResult.Success) {
|
||||||
|
rememberResolvedPlaybackUrl(item = item, file = file, url = result.url)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rememberResolvedPlaybackUrl(
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
url: String,
|
||||||
|
) {
|
||||||
|
if (url.isBlank()) return
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.withResolvedPlaybackUrl(
|
||||||
|
item = item,
|
||||||
|
file = file,
|
||||||
|
url = url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectedCloudCredentials(): List<DebridServiceCredential> =
|
||||||
|
DebridSettingsRepository.snapshot()
|
||||||
|
.takeIf { settings -> settings.cloudLibraryEnabled }
|
||||||
|
?.let(DebridProviders::configuredServices)
|
||||||
|
.orEmpty()
|
||||||
|
.filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
|
||||||
|
|
||||||
|
private fun connectedCloudConnectionKeys(): List<CloudConnectionKey> =
|
||||||
|
connectedCloudCredentials().map { credential ->
|
||||||
|
CloudConnectionKey(
|
||||||
|
providerId = credential.provider.id,
|
||||||
|
apiKeyHash = credential.apiKey.hashCode(),
|
||||||
|
)
|
||||||
|
}.sortedBy { it.providerId }
|
||||||
|
|
||||||
|
private suspend fun refreshNow(): CloudLibraryUiState {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
isEnabled = true,
|
||||||
|
isRefreshing = true,
|
||||||
|
providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val refreshed = store.refresh()
|
||||||
|
loadedConnectionKeys = connectedCloudConnectionKeys()
|
||||||
|
_uiState.value = refreshed
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CloudConnectionKey(
|
||||||
|
val providerId: String,
|
||||||
|
val apiKeyHash: Int,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun CloudLibraryUiState.findPlaybackTargetForProgress(
|
||||||
|
contentId: String,
|
||||||
|
videoId: String,
|
||||||
|
): CloudLibraryPlaybackTarget? {
|
||||||
|
val normalizedContentId = contentId.trim()
|
||||||
|
val normalizedVideoId = videoId.trim()
|
||||||
|
if (normalizedContentId.isBlank()) return null
|
||||||
|
|
||||||
|
val matchingItems = items.filter { item -> item.stableKey == normalizedContentId }
|
||||||
|
if (matchingItems.isEmpty()) return null
|
||||||
|
|
||||||
|
for (item in matchingItems) {
|
||||||
|
val exactFile = item.playableFiles.firstOrNull { file ->
|
||||||
|
item.playbackVideoId(file) == normalizedVideoId
|
||||||
|
}
|
||||||
|
if (exactFile != null) {
|
||||||
|
return CloudLibraryPlaybackTarget(item = item, file = exactFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val singleItem = matchingItems.singleOrNull() ?: return null
|
||||||
|
val singleFile = singleItem.playableFiles.singleOrNull() ?: return null
|
||||||
|
return CloudLibraryPlaybackTarget(item = singleItem, file = singleFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun CloudLibraryUiState.withResolvedPlaybackUrl(
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
url: String,
|
||||||
|
): CloudLibraryUiState {
|
||||||
|
val normalizedUrl = url.trim().takeIf { it.isNotBlank() } ?: return this
|
||||||
|
val targetItemKey = item.stableKey
|
||||||
|
val targetFileKey = file.stableKey
|
||||||
|
var didUpdate = false
|
||||||
|
val updatedProviders = providers.map { providerState ->
|
||||||
|
if (providerState.providerId != item.providerId) return@map providerState
|
||||||
|
val updatedItems = providerState.items.map { candidateItem ->
|
||||||
|
if (candidateItem.stableKey != targetItemKey) return@map candidateItem
|
||||||
|
val updatedFiles = candidateItem.files.map { candidateFile ->
|
||||||
|
if (candidateFile.stableKey != targetFileKey) {
|
||||||
|
candidateFile
|
||||||
|
} else {
|
||||||
|
didUpdate = true
|
||||||
|
candidateFile.copy(playbackUrl = normalizedUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidateItem.copy(files = updatedFiles)
|
||||||
|
}
|
||||||
|
providerState.copy(items = updatedItems)
|
||||||
|
}
|
||||||
|
return if (didUpdate) {
|
||||||
|
copy(providers = updatedProviders)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
package com.nuvio.app.features.cloud
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviders
|
||||||
|
import com.nuvio.app.features.debrid.PremiumizeApiClient
|
||||||
|
import com.nuvio.app.features.debrid.PremiumizeCloudFileDto
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
internal class PremiumizeCloudLibraryProviderApi : CloudLibraryProviderApi {
|
||||||
|
override val provider = DebridProviders.Premiumize
|
||||||
|
|
||||||
|
override suspend fun listItems(apiKey: String): Result<List<CloudLibraryItem>> =
|
||||||
|
runCatching {
|
||||||
|
val response = PremiumizeApiClient.listAllItems(apiKey)
|
||||||
|
if (!response.isSuccessful || response.body?.status.equals("error", ignoreCase = true)) {
|
||||||
|
throw IllegalStateException(response.body?.message ?: response.body?.code ?: response.rawBody.takeIf { it.isNotBlank() })
|
||||||
|
}
|
||||||
|
premiumizeCloudItemsFromFiles(
|
||||||
|
files = response.body?.files.orEmpty(),
|
||||||
|
providerId = provider.id,
|
||||||
|
providerName = provider.displayName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resolvePlayback(
|
||||||
|
apiKey: String,
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
): CloudLibraryPlaybackResult {
|
||||||
|
if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable
|
||||||
|
file.playbackUrl?.takeIf { it.isNotBlank() }?.let { url ->
|
||||||
|
return CloudLibraryPlaybackResult.Success(
|
||||||
|
url = url,
|
||||||
|
filename = file.name.takeIf { it.isNotBlank() },
|
||||||
|
videoSizeBytes = file.sizeBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileId = file.id?.takeIf { it.isNotBlank() } ?: return CloudLibraryPlaybackResult.Failed()
|
||||||
|
return try {
|
||||||
|
val response = PremiumizeApiClient.itemDetails(apiKey = apiKey, itemId = fileId)
|
||||||
|
if (!response.isSuccessful || response.body?.status.equals("error", ignoreCase = true)) {
|
||||||
|
return CloudLibraryPlaybackResult.Failed(response.body?.message ?: response.body?.code)
|
||||||
|
}
|
||||||
|
val url = response.body?.link?.takeIf { it.isNotBlank() }
|
||||||
|
?: return CloudLibraryPlaybackResult.Failed()
|
||||||
|
CloudLibraryPlaybackResult.Success(
|
||||||
|
url = url,
|
||||||
|
filename = response.body.name?.takeIf { it.isNotBlank() } ?: file.name.takeIf { it.isNotBlank() },
|
||||||
|
videoSizeBytes = response.body.size ?: file.sizeBytes,
|
||||||
|
)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
CloudLibraryPlaybackResult.Failed(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun premiumizeCloudItemsFromFiles(
|
||||||
|
files: List<PremiumizeCloudFileDto>,
|
||||||
|
providerId: String,
|
||||||
|
providerName: String,
|
||||||
|
): List<CloudLibraryItem> {
|
||||||
|
val mappedFiles = files.mapNotNull { it.toPremiumizeCloudFile() }
|
||||||
|
val groups = mappedFiles.groupBy { file ->
|
||||||
|
file.groupKey
|
||||||
|
}
|
||||||
|
return groups.values
|
||||||
|
.mapNotNull { group ->
|
||||||
|
val first = group.firstOrNull() ?: return@mapNotNull null
|
||||||
|
val cloudFiles = group
|
||||||
|
.map { it.file }
|
||||||
|
.sortedWith(compareBy<CloudLibraryFile> { !it.playable }.thenBy { it.name.lowercase() })
|
||||||
|
val size = cloudFiles
|
||||||
|
.mapNotNull { it.sizeBytes }
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.sum()
|
||||||
|
CloudLibraryItem(
|
||||||
|
providerId = providerId,
|
||||||
|
providerName = providerName,
|
||||||
|
id = first.itemId,
|
||||||
|
type = CloudLibraryItemType.File,
|
||||||
|
name = first.itemName,
|
||||||
|
status = "Ready",
|
||||||
|
sizeBytes = size,
|
||||||
|
files = cloudFiles,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PremiumizeMappedCloudFile(
|
||||||
|
val groupKey: String,
|
||||||
|
val itemId: String,
|
||||||
|
val itemName: String,
|
||||||
|
val file: CloudLibraryFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun PremiumizeCloudFileDto.toPremiumizeCloudFile(): PremiumizeMappedCloudFile? {
|
||||||
|
val normalizedPath = path?.trim()?.trim('/')?.takeIf { it.isNotBlank() }
|
||||||
|
val fileName = name?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
?: normalizedPath?.pathBasename()?.takeIf { it.isNotBlank() }
|
||||||
|
?: return null
|
||||||
|
val fileId = id?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
val playable = isPlayablePremiumizeCloudFile(name = fileName, mimeType = mimeType)
|
||||||
|
val segments = normalizedPath
|
||||||
|
?.split('/')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotBlank() }
|
||||||
|
.orEmpty()
|
||||||
|
val topLevel = segments.firstOrNull()
|
||||||
|
val isRootFile = segments.size <= 1
|
||||||
|
val itemName = if (isRootFile) fileName else topLevel ?: fileName
|
||||||
|
val itemId = if (isRootFile) {
|
||||||
|
"file:${fileId ?: normalizedPath ?: fileName}"
|
||||||
|
} else {
|
||||||
|
"folder:${topLevel ?: itemName}"
|
||||||
|
}
|
||||||
|
val groupKey = if (isRootFile) itemId else "folder:${topLevel ?: itemName}"
|
||||||
|
return PremiumizeMappedCloudFile(
|
||||||
|
groupKey = groupKey,
|
||||||
|
itemId = itemId,
|
||||||
|
itemName = itemName,
|
||||||
|
file = CloudLibraryFile(
|
||||||
|
id = fileId,
|
||||||
|
name = fileName,
|
||||||
|
sizeBytes = size,
|
||||||
|
mimeType = mimeType,
|
||||||
|
playable = playable,
|
||||||
|
playbackUrl = link?.takeIf { playable && it.isNotBlank() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.pathBasename(): String =
|
||||||
|
substringAfterLast('/').substringAfterLast('\\')
|
||||||
|
|
||||||
|
private fun isPlayablePremiumizeCloudFile(name: String, mimeType: String?): Boolean {
|
||||||
|
val normalizedMime = mimeType?.lowercase().orEmpty()
|
||||||
|
if (normalizedMime.startsWith("video/")) return true
|
||||||
|
val extension = name.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
return extension in premiumizePlayableVideoExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
private val premiumizePlayableVideoExtensions = setOf(
|
||||||
|
"3g2",
|
||||||
|
"3gp",
|
||||||
|
"avi",
|
||||||
|
"divx",
|
||||||
|
"flv",
|
||||||
|
"m2ts",
|
||||||
|
"m4v",
|
||||||
|
"mkv",
|
||||||
|
"mov",
|
||||||
|
"mp4",
|
||||||
|
"mpeg",
|
||||||
|
"mpg",
|
||||||
|
"mts",
|
||||||
|
"ogm",
|
||||||
|
"ogv",
|
||||||
|
"ts",
|
||||||
|
"webm",
|
||||||
|
"wmv",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
package com.nuvio.app.features.cloud
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviders
|
||||||
|
import com.nuvio.app.features.debrid.TorboxApiClient
|
||||||
|
import com.nuvio.app.features.debrid.TorboxCloudFileDto
|
||||||
|
import com.nuvio.app.features.debrid.TorboxCloudItemDto
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi {
|
||||||
|
override val provider = DebridProviders.Torbox
|
||||||
|
|
||||||
|
override suspend fun listItems(apiKey: String): Result<List<CloudLibraryItem>> =
|
||||||
|
runCatching {
|
||||||
|
val torrents = TorboxApiClient.listCloudTorrents(apiKey).itemsOrThrow(CloudLibraryItemType.Torrent)
|
||||||
|
val usenet = TorboxApiClient.listCloudUsenet(apiKey).itemsOrThrow(CloudLibraryItemType.Usenet)
|
||||||
|
val web = TorboxApiClient.listCloudWebDownloads(apiKey).itemsOrThrow(CloudLibraryItemType.WebDownload)
|
||||||
|
torrents + usenet + web
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resolvePlayback(
|
||||||
|
apiKey: String,
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
): CloudLibraryPlaybackResult {
|
||||||
|
if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val response = when (item.type) {
|
||||||
|
CloudLibraryItemType.Torrent -> TorboxApiClient.requestCloudTorrentDownloadLink(
|
||||||
|
apiKey = apiKey,
|
||||||
|
torrentId = item.id,
|
||||||
|
fileId = file.id,
|
||||||
|
)
|
||||||
|
CloudLibraryItemType.Usenet -> TorboxApiClient.requestCloudUsenetDownloadLink(
|
||||||
|
apiKey = apiKey,
|
||||||
|
usenetId = item.id,
|
||||||
|
fileId = file.id,
|
||||||
|
)
|
||||||
|
CloudLibraryItemType.WebDownload -> TorboxApiClient.requestCloudWebDownloadLink(
|
||||||
|
apiKey = apiKey,
|
||||||
|
webId = item.id,
|
||||||
|
fileId = file.id,
|
||||||
|
)
|
||||||
|
CloudLibraryItemType.File -> return CloudLibraryPlaybackResult.Failed()
|
||||||
|
}
|
||||||
|
if (!response.isSuccessful || response.body?.success == false) {
|
||||||
|
return CloudLibraryPlaybackResult.Failed(response.body?.detail ?: response.body?.error)
|
||||||
|
}
|
||||||
|
val url = response.body?.data?.takeIf { it.isNotBlank() }
|
||||||
|
?: return CloudLibraryPlaybackResult.Failed()
|
||||||
|
CloudLibraryPlaybackResult.Success(
|
||||||
|
url = url,
|
||||||
|
filename = file.name.takeIf { it.isNotBlank() },
|
||||||
|
videoSizeBytes = file.sizeBytes,
|
||||||
|
)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
CloudLibraryPlaybackResult.Failed(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun com.nuvio.app.features.debrid.DebridApiResponse<com.nuvio.app.features.debrid.TorboxEnvelopeDto<List<TorboxCloudItemDto>>>.itemsOrThrow(
|
||||||
|
type: CloudLibraryItemType,
|
||||||
|
): List<CloudLibraryItem> {
|
||||||
|
if (!isSuccessful || body?.success == false) {
|
||||||
|
throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() })
|
||||||
|
}
|
||||||
|
return body?.data.orEmpty().mapNotNull { dto ->
|
||||||
|
dto.toCloudLibraryItem(
|
||||||
|
providerId = provider.id,
|
||||||
|
providerName = provider.displayName,
|
||||||
|
type = type,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun TorboxCloudItemDto.toCloudLibraryItem(
|
||||||
|
providerId: String,
|
||||||
|
providerName: String,
|
||||||
|
type: CloudLibraryItemType,
|
||||||
|
): CloudLibraryItem? {
|
||||||
|
val itemId = id.scalarString()
|
||||||
|
?: hash?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
?: return null
|
||||||
|
val itemName = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId
|
||||||
|
val mappedFiles = files.orEmpty().mapNotNull { file ->
|
||||||
|
file.toCloudLibraryFile(parentName = itemName)
|
||||||
|
}
|
||||||
|
val filesSize = mappedFiles
|
||||||
|
.mapNotNull { it.sizeBytes }
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.sum()
|
||||||
|
return CloudLibraryItem(
|
||||||
|
providerId = providerId,
|
||||||
|
providerName = providerName,
|
||||||
|
id = itemId,
|
||||||
|
type = type,
|
||||||
|
name = itemName,
|
||||||
|
status = listOf(status, downloadState, state)
|
||||||
|
.firstNonBlank(),
|
||||||
|
sizeBytes = size ?: totalSize ?: filesSize,
|
||||||
|
progressFraction = listOfNotNull(progress, downloadProgress).firstOrNull()?.toProgressFraction(),
|
||||||
|
files = mappedFiles,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun TorboxCloudFileDto.toCloudLibraryFile(parentName: String? = null): CloudLibraryFile? {
|
||||||
|
val name = bestCloudFileName(parentName = parentName)
|
||||||
|
?: return null
|
||||||
|
val fileId = id.scalarString()
|
||||||
|
val mime = listOf(mimeType, mimeTypeAlt).firstNonBlank()
|
||||||
|
return CloudLibraryFile(
|
||||||
|
id = fileId,
|
||||||
|
name = name,
|
||||||
|
sizeBytes = size,
|
||||||
|
mimeType = mime,
|
||||||
|
playable = fileId != null && isPlayableCloudFile(name = name, mimeType = mime),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TorboxCloudFileDto.bestCloudFileName(parentName: String?): String? {
|
||||||
|
val rawName = name?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
val short = shortName?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
val pathName = absolutePath
|
||||||
|
?.trim()
|
||||||
|
?.pathBasename()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
val parent = parentName?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
val rawNameIsPath = rawName?.isPathLike() == true
|
||||||
|
val rawNameBasename = rawName
|
||||||
|
?.takeIf { rawNameIsPath }
|
||||||
|
?.pathBasename()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
val candidates = listOf(
|
||||||
|
short,
|
||||||
|
rawNameBasename,
|
||||||
|
rawName?.takeUnless { rawNameIsPath },
|
||||||
|
pathName,
|
||||||
|
rawName,
|
||||||
|
absolutePath?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
return candidates.firstOrNull { candidate ->
|
||||||
|
candidate?.isUsableCloudFileName(parentName = parent, pathName = pathName) == true
|
||||||
|
} ?: candidates.firstNonBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String =
|
||||||
|
when (type) {
|
||||||
|
CloudLibraryItemType.Torrent -> "torrent_id"
|
||||||
|
CloudLibraryItemType.Usenet -> "usenet_id"
|
||||||
|
CloudLibraryItemType.WebDownload -> "web_id"
|
||||||
|
CloudLibraryItemType.File -> "file_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String?>.firstNonBlank(): String? =
|
||||||
|
firstOrNull { !it.isNullOrBlank() }?.trim()
|
||||||
|
|
||||||
|
private fun String.sameDisplayName(other: String?): Boolean {
|
||||||
|
val normalized = normalizeDisplayName()
|
||||||
|
return normalized.isNotBlank() && normalized == other?.normalizeDisplayName()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isUsableCloudFileName(parentName: String?, pathName: String?): Boolean {
|
||||||
|
if (isBlank() || sameDisplayName(parentName)) return false
|
||||||
|
val pathNameWithoutExtension = pathName?.substringBeforeLast('.', pathName)
|
||||||
|
if (!contains('.') && sameDisplayName(pathNameWithoutExtension)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isPathLike(): Boolean =
|
||||||
|
contains('/') || contains('\\')
|
||||||
|
|
||||||
|
private fun String.pathBasename(): String =
|
||||||
|
substringAfterLast('/').substringAfterLast('\\')
|
||||||
|
|
||||||
|
private fun String.normalizeDisplayName(): String =
|
||||||
|
trim()
|
||||||
|
.substringAfterLast('/')
|
||||||
|
.substringAfterLast('\\')
|
||||||
|
.substringBeforeLast('.', this)
|
||||||
|
.lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9]+"), " ")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
private fun kotlinx.serialization.json.JsonElement?.scalarString(): String? {
|
||||||
|
val primitive = this as? JsonPrimitive ?: return null
|
||||||
|
return primitive.content.trim().takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Double.toProgressFraction(): Float {
|
||||||
|
val normalized = if (this > 1.0) this / 100.0 else this
|
||||||
|
return normalized.toFloat().coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPlayableCloudFile(name: String, mimeType: String?): Boolean {
|
||||||
|
val normalizedMime = mimeType?.lowercase().orEmpty()
|
||||||
|
if (normalizedMime.startsWith("video/")) return true
|
||||||
|
val extension = name.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
return extension in playableVideoExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
private val playableVideoExtensions = setOf(
|
||||||
|
"3g2",
|
||||||
|
"3gp",
|
||||||
|
"avi",
|
||||||
|
"divx",
|
||||||
|
"flv",
|
||||||
|
"m2ts",
|
||||||
|
"m4v",
|
||||||
|
"mkv",
|
||||||
|
"mov",
|
||||||
|
"mp4",
|
||||||
|
"mpeg",
|
||||||
|
"mpg",
|
||||||
|
"mts",
|
||||||
|
"ogm",
|
||||||
|
"ogv",
|
||||||
|
"ts",
|
||||||
|
"webm",
|
||||||
|
"wmv",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,566 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.addons.RawHttpResponse
|
||||||
|
import com.nuvio.app.features.addons.httpRequestRaw
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
internal data class DebridApiResponse<T>(
|
||||||
|
val status: Int,
|
||||||
|
val body: T?,
|
||||||
|
val rawBody: String,
|
||||||
|
) {
|
||||||
|
val isSuccessful: Boolean
|
||||||
|
get() = status in 200..299
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object DebridApiJson {
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object TorboxApiClient {
|
||||||
|
private const val BASE_URL = "https://api.torbox.app"
|
||||||
|
|
||||||
|
suspend fun startDeviceAuthorization(
|
||||||
|
appName: String,
|
||||||
|
): DebridApiResponse<TorboxEnvelopeDto<TorboxDeviceAuthorizationDto>> =
|
||||||
|
requestWithoutAuth(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/user/auth/device/start?${
|
||||||
|
queryString("app" to appName)
|
||||||
|
}",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun redeemDeviceAuthorization(
|
||||||
|
deviceCode: String,
|
||||||
|
): DebridApiResponse<TorboxEnvelopeDto<TorboxDeviceTokenDto>> =
|
||||||
|
requestWithoutAuth(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/v1/api/user/auth/device/token",
|
||||||
|
body = DebridApiJson.json.encodeToString(TorboxDeviceTokenRequestDto(deviceCode = deviceCode)),
|
||||||
|
contentType = "application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun validateApiKey(apiKey: String): Boolean =
|
||||||
|
getUser(apiKey.trim()).status in 200..299
|
||||||
|
|
||||||
|
private suspend fun getUser(apiKey: String): RawHttpResponse =
|
||||||
|
httpRequestRaw(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/user/me",
|
||||||
|
headers = authHeaders(apiKey),
|
||||||
|
body = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun checkCached(
|
||||||
|
apiKey: String,
|
||||||
|
hashes: List<String>,
|
||||||
|
): DebridApiResponse<TorboxEnvelopeDto<Map<String, TorboxCachedItemDto>>> {
|
||||||
|
val normalizedHashes = hashes
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
if (normalizedHashes.isEmpty()) {
|
||||||
|
return DebridApiResponse(
|
||||||
|
status = 200,
|
||||||
|
body = TorboxEnvelopeDto(success = true, data = emptyMap()),
|
||||||
|
rawBody = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val body = DebridApiJson.json.encodeToString(
|
||||||
|
TorboxCheckCachedRequestDto(hashes = normalizedHashes),
|
||||||
|
)
|
||||||
|
return request(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/v1/api/torrents/checkcached?format=object",
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = body,
|
||||||
|
contentType = "application/json",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createTorrent(apiKey: String, magnet: String): DebridApiResponse<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>> {
|
||||||
|
val boundary = "NuvioDebrid${magnet.hashCode().toUInt()}"
|
||||||
|
val body = multipartFormBody(
|
||||||
|
boundary = boundary,
|
||||||
|
"magnet" to magnet,
|
||||||
|
"add_only_if_cached" to "true",
|
||||||
|
"allow_zip" to "false",
|
||||||
|
)
|
||||||
|
return request(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/v1/api/torrents/createtorrent",
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = body,
|
||||||
|
contentType = "multipart/form-data; boundary=$boundary",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTorrent(apiKey: String, id: Int): DebridApiResponse<TorboxEnvelopeDto<TorboxTorrentDataDto>> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/torrents/mylist?${
|
||||||
|
queryString(
|
||||||
|
"id" to id.toString(),
|
||||||
|
"bypass_cache" to "true",
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun listCloudTorrents(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/torrents/mylist",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun listCloudUsenet(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/usenet/mylist",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/webdl/mylist",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun requestDownloadLink(
|
||||||
|
apiKey: String,
|
||||||
|
torrentId: Int,
|
||||||
|
fileId: Int?,
|
||||||
|
): DebridApiResponse<TorboxEnvelopeDto<String>> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/torrents/requestdl?${
|
||||||
|
queryString(
|
||||||
|
"token" to apiKey,
|
||||||
|
"torrent_id" to torrentId.toString(),
|
||||||
|
"file_id" to fileId?.toString(),
|
||||||
|
"zip_link" to "false",
|
||||||
|
"redirect" to "false",
|
||||||
|
"append_name" to "false",
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun requestCloudTorrentDownloadLink(
|
||||||
|
apiKey: String,
|
||||||
|
torrentId: String,
|
||||||
|
fileId: String?,
|
||||||
|
): DebridApiResponse<TorboxEnvelopeDto<String>> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/torrents/requestdl?${
|
||||||
|
queryString(
|
||||||
|
"token" to apiKey,
|
||||||
|
"torrent_id" to torrentId,
|
||||||
|
"file_id" to fileId,
|
||||||
|
"zip_link" to "false",
|
||||||
|
"redirect" to "false",
|
||||||
|
"append_name" to "false",
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun requestCloudUsenetDownloadLink(
|
||||||
|
apiKey: String,
|
||||||
|
usenetId: String,
|
||||||
|
fileId: String?,
|
||||||
|
): DebridApiResponse<TorboxEnvelopeDto<String>> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/usenet/requestdl?${
|
||||||
|
queryString(
|
||||||
|
"token" to apiKey,
|
||||||
|
"usenet_id" to usenetId,
|
||||||
|
"file_id" to fileId,
|
||||||
|
"zip_link" to "false",
|
||||||
|
"redirect" to "false",
|
||||||
|
"append_name" to "false",
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun requestCloudWebDownloadLink(
|
||||||
|
apiKey: String,
|
||||||
|
webId: String,
|
||||||
|
fileId: String?,
|
||||||
|
): DebridApiResponse<TorboxEnvelopeDto<String>> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/v1/api/webdl/requestdl?${
|
||||||
|
queryString(
|
||||||
|
"token" to apiKey,
|
||||||
|
"web_id" to webId,
|
||||||
|
"file_id" to fileId,
|
||||||
|
"zip_link" to "false",
|
||||||
|
"redirect" to "false",
|
||||||
|
"append_name" to "false",
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> request(
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
body: String = "",
|
||||||
|
contentType: String? = null,
|
||||||
|
): DebridApiResponse<T> {
|
||||||
|
val headers = authHeaders(apiKey) + listOfNotNull(
|
||||||
|
contentType?.let { "Content-Type" to it },
|
||||||
|
"Accept" to "application/json",
|
||||||
|
)
|
||||||
|
val response = httpRequestRaw(
|
||||||
|
method = method,
|
||||||
|
url = url,
|
||||||
|
headers = headers,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
return DebridApiResponse(
|
||||||
|
status = response.status,
|
||||||
|
body = response.decodeBody<T>(),
|
||||||
|
rawBody = response.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> requestWithoutAuth(
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
body: String = "",
|
||||||
|
contentType: String? = null,
|
||||||
|
): DebridApiResponse<T> {
|
||||||
|
val headers = listOfNotNull(
|
||||||
|
contentType?.let { "Content-Type" to it },
|
||||||
|
"Accept" to "application/json",
|
||||||
|
).toMap()
|
||||||
|
val response = httpRequestRaw(
|
||||||
|
method = method,
|
||||||
|
url = url,
|
||||||
|
headers = headers,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
return DebridApiResponse(
|
||||||
|
status = response.status,
|
||||||
|
body = response.decodeBody<T>(),
|
||||||
|
rawBody = response.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authHeaders(apiKey: String): Map<String, String> =
|
||||||
|
mapOf("Authorization" to "Bearer $apiKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object RealDebridApiClient {
|
||||||
|
private const val BASE_URL = "https://api.real-debrid.com/rest/1.0"
|
||||||
|
|
||||||
|
suspend fun validateApiKey(apiKey: String): Boolean =
|
||||||
|
httpRequestRaw(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/user",
|
||||||
|
headers = authHeaders(apiKey.trim()),
|
||||||
|
body = "",
|
||||||
|
).status in 200..299
|
||||||
|
|
||||||
|
suspend fun addMagnet(apiKey: String, magnet: String): DebridApiResponse<RealDebridAddTorrentDto> =
|
||||||
|
formRequest(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/torrents/addMagnet",
|
||||||
|
apiKey = apiKey,
|
||||||
|
fields = listOf("magnet" to magnet),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun getTorrentInfo(apiKey: String, id: String): DebridApiResponse<RealDebridTorrentInfoDto> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/torrents/info/${encodePathSegment(id)}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun selectFiles(apiKey: String, id: String, files: String): DebridApiResponse<Unit> =
|
||||||
|
formRequest(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/torrents/selectFiles/${encodePathSegment(id)}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
fields = listOf("files" to files),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun unrestrictLink(apiKey: String, link: String): DebridApiResponse<RealDebridUnrestrictLinkDto> =
|
||||||
|
formRequest(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/unrestrict/link",
|
||||||
|
apiKey = apiKey,
|
||||||
|
fields = listOf("link" to link),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun deleteTorrent(apiKey: String, id: String): DebridApiResponse<Unit> =
|
||||||
|
request(
|
||||||
|
method = "DELETE",
|
||||||
|
url = "$BASE_URL/torrents/delete/${encodePathSegment(id)}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> formRequest(
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
fields: List<Pair<String, String>>,
|
||||||
|
): DebridApiResponse<T> {
|
||||||
|
val body = fields.joinToString("&") { (key, value) ->
|
||||||
|
"${encodeFormValue(key)}=${encodeFormValue(value)}"
|
||||||
|
}
|
||||||
|
return request(
|
||||||
|
method = method,
|
||||||
|
url = url,
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = body,
|
||||||
|
contentType = "application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> request(
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
body: String = "",
|
||||||
|
contentType: String? = null,
|
||||||
|
): DebridApiResponse<T> {
|
||||||
|
val headers = authHeaders(apiKey) + listOfNotNull(
|
||||||
|
contentType?.let { "Content-Type" to it },
|
||||||
|
"Accept" to "application/json",
|
||||||
|
)
|
||||||
|
val response = httpRequestRaw(
|
||||||
|
method = method,
|
||||||
|
url = url,
|
||||||
|
headers = headers,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
return DebridApiResponse(
|
||||||
|
status = response.status,
|
||||||
|
body = response.decodeBody<T>(),
|
||||||
|
rawBody = response.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authHeaders(apiKey: String): Map<String, String> =
|
||||||
|
mapOf("Authorization" to "Bearer $apiKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object PremiumizeApiClient {
|
||||||
|
private const val BASE_URL = "https://www.premiumize.me"
|
||||||
|
|
||||||
|
suspend fun validateApiKey(apiKey: String): Boolean {
|
||||||
|
val response = accountInfo(apiKey.trim())
|
||||||
|
return response.isSuccessful && response.body?.isSuccess == true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startDeviceAuthorization(
|
||||||
|
clientId: String,
|
||||||
|
): DebridApiResponse<PremiumizeDeviceAuthorizationDto> =
|
||||||
|
formRequestWithoutAuth(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/token",
|
||||||
|
fields = listOf(
|
||||||
|
"response_type" to "device_code",
|
||||||
|
"client_id" to clientId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun redeemDeviceAuthorization(
|
||||||
|
clientId: String,
|
||||||
|
deviceCode: String,
|
||||||
|
): DebridApiResponse<PremiumizeDeviceTokenDto> =
|
||||||
|
formRequestWithoutAuth(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/token",
|
||||||
|
fields = listOf(
|
||||||
|
"grant_type" to "device_code",
|
||||||
|
"code" to deviceCode,
|
||||||
|
"client_id" to clientId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun accountInfo(apiKey: String): DebridApiResponse<PremiumizeAccountInfoDto> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/api/account/info",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun listAllItems(apiKey: String): DebridApiResponse<PremiumizeItemListAllDto> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/api/item/listall",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun itemDetails(
|
||||||
|
apiKey: String,
|
||||||
|
itemId: String,
|
||||||
|
): DebridApiResponse<PremiumizeItemDetailsDto> =
|
||||||
|
request(
|
||||||
|
method = "GET",
|
||||||
|
url = "$BASE_URL/api/item/details?${queryString("id" to itemId)}",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun directDownload(
|
||||||
|
apiKey: String,
|
||||||
|
source: String,
|
||||||
|
): DebridApiResponse<PremiumizeDirectDownloadDto> =
|
||||||
|
formRequest(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/api/transfer/directdl",
|
||||||
|
apiKey = apiKey,
|
||||||
|
fields = listOf("src" to source),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun checkCache(
|
||||||
|
apiKey: String,
|
||||||
|
items: List<String>,
|
||||||
|
): DebridApiResponse<PremiumizeCacheCheckDto> {
|
||||||
|
val normalizedItems = items.map { it.trim() }.filter { it.isNotBlank() }
|
||||||
|
if (normalizedItems.isEmpty()) {
|
||||||
|
return DebridApiResponse(
|
||||||
|
status = 200,
|
||||||
|
body = PremiumizeCacheCheckDto(status = "success", response = emptyList()),
|
||||||
|
rawBody = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return formRequest(
|
||||||
|
method = "POST",
|
||||||
|
url = "$BASE_URL/api/cache/check",
|
||||||
|
apiKey = apiKey,
|
||||||
|
fields = normalizedItems.map { "items[]" to it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> formRequestWithoutAuth(
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
fields: List<Pair<String, String>>,
|
||||||
|
): DebridApiResponse<T> =
|
||||||
|
requestWithoutAuth(
|
||||||
|
method = method,
|
||||||
|
url = url,
|
||||||
|
body = formBody(fields),
|
||||||
|
contentType = "application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> formRequest(
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
fields: List<Pair<String, String>>,
|
||||||
|
): DebridApiResponse<T> =
|
||||||
|
request(
|
||||||
|
method = method,
|
||||||
|
url = url,
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = formBody(fields),
|
||||||
|
contentType = "application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> requestWithoutAuth(
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
body: String = "",
|
||||||
|
contentType: String? = null,
|
||||||
|
): DebridApiResponse<T> {
|
||||||
|
val headers = listOfNotNull(
|
||||||
|
contentType?.let { "Content-Type" to it },
|
||||||
|
"Accept" to "application/json",
|
||||||
|
).toMap()
|
||||||
|
val response = httpRequestRaw(
|
||||||
|
method = method,
|
||||||
|
url = url,
|
||||||
|
headers = headers,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
return DebridApiResponse(
|
||||||
|
status = response.status,
|
||||||
|
body = response.decodeBody<T>(),
|
||||||
|
rawBody = response.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> request(
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
body: String = "",
|
||||||
|
contentType: String? = null,
|
||||||
|
): DebridApiResponse<T> {
|
||||||
|
val headers = authHeaders(apiKey) + listOfNotNull(
|
||||||
|
contentType?.let { "Content-Type" to it },
|
||||||
|
"Accept" to "application/json",
|
||||||
|
)
|
||||||
|
val response = httpRequestRaw(
|
||||||
|
method = method,
|
||||||
|
url = url,
|
||||||
|
headers = headers,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
return DebridApiResponse(
|
||||||
|
status = response.status,
|
||||||
|
body = response.decodeBody<T>(),
|
||||||
|
rawBody = response.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formBody(fields: List<Pair<String, String>>): String =
|
||||||
|
fields.joinToString("&") { (key, value) ->
|
||||||
|
"${encodeFormValue(key)}=${encodeFormValue(value)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authHeaders(apiKey: String): Map<String, String> =
|
||||||
|
mapOf("Authorization" to "Bearer $apiKey")
|
||||||
|
|
||||||
|
private val PremiumizeAccountInfoDto.isSuccess: Boolean
|
||||||
|
get() = status.equals("success", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
object DebridCredentialValidator {
|
||||||
|
suspend fun validateProvider(providerId: String, apiKey: String): Boolean {
|
||||||
|
val normalized = apiKey.trim()
|
||||||
|
if (normalized.isBlank()) return false
|
||||||
|
return DebridProviderApis.apiFor(providerId)?.validateApiKey(normalized) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> RawHttpResponse.decodeBody(): T? {
|
||||||
|
if (body.isBlank() || T::class == Unit::class) return null
|
||||||
|
return try {
|
||||||
|
DebridApiJson.json.decodeFromString<T>(body)
|
||||||
|
} catch (_: SerializationException) {
|
||||||
|
null
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun multipartFormBody(boundary: String, vararg fields: Pair<String, String>): String =
|
||||||
|
buildString {
|
||||||
|
fields.forEach { (name, value) ->
|
||||||
|
append("--").append(boundary).append("\r\n")
|
||||||
|
append("Content-Disposition: form-data; name=\"").append(name).append("\"\r\n\r\n")
|
||||||
|
append(value).append("\r\n")
|
||||||
|
}
|
||||||
|
append("--").append(boundary).append("--\r\n")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxEnvelopeDto<T>(
|
||||||
|
val success: Boolean? = null,
|
||||||
|
val data: T? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val detail: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxCreateTorrentDataDto(
|
||||||
|
@SerialName("torrent_id") val torrentId: Int? = null,
|
||||||
|
val id: Int? = null,
|
||||||
|
val hash: String? = null,
|
||||||
|
@SerialName("auth_id") val authId: String? = null,
|
||||||
|
) {
|
||||||
|
fun resolvedTorrentId(): Int? = torrentId ?: id
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxTorrentDataDto(
|
||||||
|
val id: Int? = null,
|
||||||
|
val hash: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val files: List<TorboxTorrentFileDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxTorrentFileDto(
|
||||||
|
val id: Int? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("short_name") val shortName: String? = null,
|
||||||
|
@SerialName("absolute_path") val absolutePath: String? = null,
|
||||||
|
@SerialName("mimetype") val mimeType: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
) {
|
||||||
|
fun displayName(): String =
|
||||||
|
listOfNotNull(name, shortName, absolutePath)
|
||||||
|
.firstOrNull { it.isNotBlank() }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxCloudItemDto(
|
||||||
|
val id: JsonElement? = null,
|
||||||
|
val hash: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val state: String? = null,
|
||||||
|
@SerialName("download_state") val downloadState: String? = null,
|
||||||
|
val progress: Double? = null,
|
||||||
|
@SerialName("download_progress") val downloadProgress: Double? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
@SerialName("total_size") val totalSize: Long? = null,
|
||||||
|
val files: List<TorboxCloudFileDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxCloudFileDto(
|
||||||
|
val id: JsonElement? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("short_name") val shortName: String? = null,
|
||||||
|
@SerialName("absolute_path") val absolutePath: String? = null,
|
||||||
|
@SerialName("mimetype") val mimeType: String? = null,
|
||||||
|
@SerialName("mime_type") val mimeTypeAlt: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxCheckCachedRequestDto(
|
||||||
|
val hashes: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxDeviceAuthorizationDto(
|
||||||
|
@SerialName("device_code") val deviceCode: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
@SerialName("verification_url") val verificationUrl: String? = null,
|
||||||
|
@SerialName("friendly_verification_url") val friendlyVerificationUrl: String? = null,
|
||||||
|
val interval: Int? = null,
|
||||||
|
@SerialName("expires_at") val expiresAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxDeviceTokenRequestDto(
|
||||||
|
@SerialName("device_code") val deviceCode: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxDeviceTokenDto(
|
||||||
|
@SerialName("access_token") val accessToken: String? = null,
|
||||||
|
@SerialName("token_type") val tokenType: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class TorboxCachedItemDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
val hash: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RealDebridAddTorrentDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val uri: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RealDebridTorrentInfoDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val filename: String? = null,
|
||||||
|
@SerialName("original_filename") val originalFilename: String? = null,
|
||||||
|
val hash: String? = null,
|
||||||
|
val bytes: Long? = null,
|
||||||
|
@SerialName("original_bytes") val originalBytes: Long? = null,
|
||||||
|
val host: String? = null,
|
||||||
|
val split: Int? = null,
|
||||||
|
val progress: Int? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val files: List<RealDebridTorrentFileDto>? = null,
|
||||||
|
val links: List<String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RealDebridTorrentFileDto(
|
||||||
|
val id: Int? = null,
|
||||||
|
val path: String? = null,
|
||||||
|
val bytes: Long? = null,
|
||||||
|
val selected: Int? = null,
|
||||||
|
) {
|
||||||
|
fun displayName(): String =
|
||||||
|
path.orEmpty().substringAfterLast('/').ifBlank { path.orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RealDebridUnrestrictLinkDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val filename: String? = null,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val filesize: Long? = null,
|
||||||
|
val link: String? = null,
|
||||||
|
val host: String? = null,
|
||||||
|
val chunks: Int? = null,
|
||||||
|
val crc: Int? = null,
|
||||||
|
val download: String? = null,
|
||||||
|
val streamable: Int? = null,
|
||||||
|
val type: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeDeviceAuthorizationDto(
|
||||||
|
@SerialName("device_code") val deviceCode: String? = null,
|
||||||
|
@SerialName("user_code") val userCode: String? = null,
|
||||||
|
@SerialName("verification_uri") val verificationUri: String? = null,
|
||||||
|
@SerialName("verification_uri_complete") val verificationUriComplete: String? = null,
|
||||||
|
@SerialName("expires_in") val expiresIn: Int? = null,
|
||||||
|
val interval: Int? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
@SerialName("error_description") val errorDescription: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeDeviceTokenDto(
|
||||||
|
@SerialName("access_token") val accessToken: String? = null,
|
||||||
|
@SerialName("token_type") val tokenType: String? = null,
|
||||||
|
@SerialName("expires_in") val expiresIn: Int? = null,
|
||||||
|
val scope: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
@SerialName("error_description") val errorDescription: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeApiEnvelopeDto(
|
||||||
|
val status: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeAccountInfoDto(
|
||||||
|
val status: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
@SerialName("customer_id") val customerId: String? = null,
|
||||||
|
@SerialName("premium_until") val premiumUntil: Long? = null,
|
||||||
|
@SerialName("limit_used") val limitUsed: Double? = null,
|
||||||
|
@SerialName("booster_points") val boosterPoints: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeDirectDownloadDto(
|
||||||
|
val status: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
val content: List<PremiumizeDirectDownloadFileDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeDirectDownloadFileDto(
|
||||||
|
val path: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
val link: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeCacheCheckDto(
|
||||||
|
val status: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
val response: List<Boolean>? = null,
|
||||||
|
val filename: List<String?>? = null,
|
||||||
|
val filesize: List<JsonElement?>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeItemListAllDto(
|
||||||
|
val status: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
val files: List<PremiumizeCloudFileDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeCloudFileDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val path: String? = null,
|
||||||
|
val type: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
@SerialName("created_at") val createdAt: Long? = null,
|
||||||
|
@SerialName("mime_type") val mimeType: String? = null,
|
||||||
|
val link: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class PremiumizeItemDetailsDto(
|
||||||
|
val status: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
val id: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
@SerialName("created_at") val createdAt: Long? = null,
|
||||||
|
@SerialName("folder_id") val folderId: String? = null,
|
||||||
|
@SerialName("mime_type") val mimeType: String? = null,
|
||||||
|
val link: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolve
|
||||||
|
|
||||||
|
internal class TorboxFileSelector {
|
||||||
|
fun selectFile(
|
||||||
|
files: List<TorboxTorrentFileDto>,
|
||||||
|
resolve: StreamClientResolve,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): TorboxTorrentFileDto? {
|
||||||
|
val playable = files.filter { it.isPlayableVideo() }
|
||||||
|
if (playable.isEmpty()) return null
|
||||||
|
|
||||||
|
val episodePatterns = buildEpisodePatterns(
|
||||||
|
season = season ?: resolve.season,
|
||||||
|
episode = episode ?: resolve.episode,
|
||||||
|
)
|
||||||
|
val names = resolve.specificFileNames(episodePatterns)
|
||||||
|
if (names.isNotEmpty()) {
|
||||||
|
playable.firstNameMatch(names) { it.displayName() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodePatterns.isNotEmpty()) {
|
||||||
|
playable.firstOrNull { file ->
|
||||||
|
val fileName = file.displayName().lowercase()
|
||||||
|
episodePatterns.any { pattern -> fileName.contains(pattern) }
|
||||||
|
}?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve.fileIdx?.let { fileIdx ->
|
||||||
|
files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
if (fileIdx > 0) {
|
||||||
|
files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playable.firstOrNull { it.id == fileIdx }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playable.maxByOrNull { it.size ?: 0L }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TorboxTorrentFileDto.isPlayableVideo(): Boolean {
|
||||||
|
val mime = mimeType.orEmpty().lowercase()
|
||||||
|
if (mime.startsWith("video/")) return true
|
||||||
|
return displayName().lowercase().hasVideoExtension()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class RealDebridFileSelector {
|
||||||
|
fun selectFile(
|
||||||
|
files: List<RealDebridTorrentFileDto>,
|
||||||
|
resolve: StreamClientResolve,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): RealDebridTorrentFileDto? {
|
||||||
|
val playable = files.filter { it.isPlayableVideo() }
|
||||||
|
if (playable.isEmpty()) return null
|
||||||
|
|
||||||
|
val episodePatterns = buildEpisodePatterns(
|
||||||
|
season = season ?: resolve.season,
|
||||||
|
episode = episode ?: resolve.episode,
|
||||||
|
)
|
||||||
|
val names = resolve.specificFileNames(episodePatterns)
|
||||||
|
if (names.isNotEmpty()) {
|
||||||
|
playable.firstNameMatch(names) { it.displayName() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodePatterns.isNotEmpty()) {
|
||||||
|
playable.firstOrNull { file ->
|
||||||
|
val fileName = file.displayName().lowercase()
|
||||||
|
episodePatterns.any { pattern -> fileName.contains(pattern) }
|
||||||
|
}?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve.fileIdx?.let { fileIdx ->
|
||||||
|
files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
if (fileIdx > 0) {
|
||||||
|
files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playable.firstOrNull { it.id == fileIdx }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playable.maxByOrNull { it.bytes ?: 0L }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RealDebridTorrentFileDto.isPlayableVideo(): Boolean =
|
||||||
|
displayName().lowercase().hasVideoExtension()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class PremiumizeDirectDownloadFileSelector {
|
||||||
|
fun selectFile(
|
||||||
|
files: List<PremiumizeDirectDownloadFileDto>,
|
||||||
|
resolve: StreamClientResolve,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): PremiumizeDirectDownloadFileDto? {
|
||||||
|
val playable = files.filter { it.isPlayableVideo() }
|
||||||
|
if (playable.isEmpty()) return null
|
||||||
|
|
||||||
|
val episodePatterns = buildEpisodePatterns(
|
||||||
|
season = season ?: resolve.season,
|
||||||
|
episode = episode ?: resolve.episode,
|
||||||
|
)
|
||||||
|
val names = resolve.specificFileNames(episodePatterns)
|
||||||
|
if (names.isNotEmpty()) {
|
||||||
|
playable.firstNameMatch(names) { it.displayName() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodePatterns.isNotEmpty()) {
|
||||||
|
playable.firstOrNull { file ->
|
||||||
|
val fileName = file.displayName().lowercase()
|
||||||
|
episodePatterns.any { pattern -> fileName.contains(pattern) }
|
||||||
|
}?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve.fileIdx?.let { fileIdx ->
|
||||||
|
files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
if (fileIdx > 0) {
|
||||||
|
files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playable.maxByOrNull { it.size ?: 0L }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PremiumizeDirectDownloadFileDto.isPlayableVideo(): Boolean =
|
||||||
|
!link.isNullOrBlank() && displayName().lowercase().hasVideoExtension()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PremiumizeDirectDownloadFileDto.displayName(): String =
|
||||||
|
path.orEmpty().substringAfterLast('/').substringAfterLast('\\').ifBlank { path.orEmpty() }
|
||||||
|
|
||||||
|
private fun String.normalizedName(): String =
|
||||||
|
substringAfterLast('/')
|
||||||
|
.substringBeforeLast('.')
|
||||||
|
.lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9]+"), " ")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
private fun StreamClientResolve.specificFileNames(episodePatterns: List<String>): List<String> {
|
||||||
|
val raw = stream?.raw
|
||||||
|
return listOfNotNull(
|
||||||
|
filename,
|
||||||
|
raw?.filename,
|
||||||
|
raw?.parsed?.rawTitle?.takeIf { it.looksSpecificForSelection(episodePatterns) },
|
||||||
|
torrentName?.takeIf { it.looksSpecificForSelection(episodePatterns) },
|
||||||
|
)
|
||||||
|
.map { it.normalizedName() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.looksSpecificForSelection(episodePatterns: List<String>): Boolean {
|
||||||
|
val lower = lowercase()
|
||||||
|
return lower.hasVideoExtension() || episodePatterns.any { pattern -> lower.contains(pattern) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> List<T>.firstNameMatch(
|
||||||
|
names: List<String>,
|
||||||
|
displayName: (T) -> String,
|
||||||
|
): T? =
|
||||||
|
firstOrNull { item ->
|
||||||
|
val fileName = displayName(item).normalizedName()
|
||||||
|
names.any { name -> fileName.contains(name) || name.contains(fileName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEpisodePatterns(season: Int?, episode: Int?): List<String> {
|
||||||
|
if (season == null || episode == null) return emptyList()
|
||||||
|
val seasonTwo = season.toString().padStart(2, '0')
|
||||||
|
val episodeTwo = episode.toString().padStart(2, '0')
|
||||||
|
return listOf(
|
||||||
|
"s${seasonTwo}e$episodeTwo",
|
||||||
|
"${season}x$episodeTwo",
|
||||||
|
"${season}x$episode",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.hasVideoExtension(): Boolean =
|
||||||
|
videoExtensions.any { endsWith(it) }
|
||||||
|
|
||||||
|
private val videoExtensions = setOf(
|
||||||
|
".mp4",
|
||||||
|
".mkv",
|
||||||
|
".webm",
|
||||||
|
".avi",
|
||||||
|
".mov",
|
||||||
|
".m4v",
|
||||||
|
".ts",
|
||||||
|
".m2ts",
|
||||||
|
".wmv",
|
||||||
|
".flv",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
|
||||||
|
internal object DebridMagnetBuilder {
|
||||||
|
fun fromStream(stream: StreamItem): String? {
|
||||||
|
stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it }
|
||||||
|
val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return buildString {
|
||||||
|
append("magnet:?xt=urn:btih:")
|
||||||
|
append(hash)
|
||||||
|
stream.behaviorHints.filename
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { filename ->
|
||||||
|
append("&dn=")
|
||||||
|
append(encodePathSegment(filename))
|
||||||
|
}
|
||||||
|
stream.sources
|
||||||
|
.mapNotNull(::trackerUrl)
|
||||||
|
.distinct()
|
||||||
|
.forEach { tracker ->
|
||||||
|
append("&tr=")
|
||||||
|
append(encodePathSegment(tracker))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackerUrl(source: String): String? {
|
||||||
|
val value = source.trim()
|
||||||
|
if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null
|
||||||
|
return value
|
||||||
|
.removePrefix("tracker:")
|
||||||
|
.trim()
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
data class DebridProvider(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
|
val visibleInUi: Boolean = true,
|
||||||
|
val authMethod: DebridProviderAuthMethod = DebridProviderAuthMethod.ApiKey,
|
||||||
|
val capabilities: Set<DebridProviderCapability> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DebridServiceCredential(
|
||||||
|
val provider: DebridProvider,
|
||||||
|
val apiKey: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DebridProviderCapability {
|
||||||
|
ClientResolve,
|
||||||
|
LocalTorrentCacheCheck,
|
||||||
|
LocalTorrentResolve,
|
||||||
|
CloudLibrary,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DebridProviderAuthMethod {
|
||||||
|
ApiKey,
|
||||||
|
DeviceCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
object DebridProviders {
|
||||||
|
const val TORBOX_ID = "torbox"
|
||||||
|
const val PREMIUMIZE_ID = "premiumize"
|
||||||
|
const val REAL_DEBRID_ID = "realdebrid"
|
||||||
|
|
||||||
|
val Torbox = DebridProvider(
|
||||||
|
id = TORBOX_ID,
|
||||||
|
displayName = "Torbox",
|
||||||
|
shortName = "TB",
|
||||||
|
authMethod = DebridProviderAuthMethod.DeviceCode,
|
||||||
|
capabilities = setOf(
|
||||||
|
DebridProviderCapability.ClientResolve,
|
||||||
|
DebridProviderCapability.LocalTorrentCacheCheck,
|
||||||
|
DebridProviderCapability.LocalTorrentResolve,
|
||||||
|
DebridProviderCapability.CloudLibrary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val Premiumize = DebridProvider(
|
||||||
|
id = PREMIUMIZE_ID,
|
||||||
|
displayName = "Premiumize",
|
||||||
|
shortName = "PM",
|
||||||
|
authMethod = DebridProviderAuthMethod.DeviceCode,
|
||||||
|
capabilities = setOf(
|
||||||
|
DebridProviderCapability.ClientResolve,
|
||||||
|
DebridProviderCapability.LocalTorrentCacheCheck,
|
||||||
|
DebridProviderCapability.LocalTorrentResolve,
|
||||||
|
DebridProviderCapability.CloudLibrary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val RealDebrid = DebridProvider(
|
||||||
|
id = REAL_DEBRID_ID,
|
||||||
|
displayName = "Real-Debrid",
|
||||||
|
shortName = "RD",
|
||||||
|
visibleInUi = false,
|
||||||
|
capabilities = setOf(DebridProviderCapability.ClientResolve),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val registered = listOf(Torbox, Premiumize, RealDebrid)
|
||||||
|
|
||||||
|
fun all(): List<DebridProvider> = registered
|
||||||
|
|
||||||
|
fun visible(): List<DebridProvider> = registered.filter { it.visibleInUi }
|
||||||
|
|
||||||
|
fun byId(id: String?): DebridProvider? {
|
||||||
|
val normalized = id?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return registered.firstOrNull { it.id.equals(normalized, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSupported(id: String?): Boolean = byId(id) != null
|
||||||
|
|
||||||
|
fun isVisible(id: String?): Boolean = byId(id)?.visibleInUi == true
|
||||||
|
|
||||||
|
fun instantName(id: String?): String = "${displayName(id)} Instant"
|
||||||
|
|
||||||
|
fun addonId(id: String?): String =
|
||||||
|
"debrid:${byId(id)?.id ?: id?.trim().orEmpty().ifBlank { "unknown" }}"
|
||||||
|
|
||||||
|
fun displayName(id: String?): String =
|
||||||
|
byId(id)?.displayName ?: id.toFallbackDisplayName()
|
||||||
|
|
||||||
|
fun shortName(id: String?): String =
|
||||||
|
byId(id)?.shortName ?: id?.trim()?.takeIf { it.isNotBlank() }?.uppercase().orEmpty()
|
||||||
|
|
||||||
|
fun configuredServices(settings: DebridSettings): List<DebridServiceCredential> =
|
||||||
|
registered.mapNotNull { provider ->
|
||||||
|
settings.apiKeyFor(provider.id)
|
||||||
|
.trim()
|
||||||
|
.takeIf { provider.visibleInUi && it.isNotBlank() }
|
||||||
|
?.let { apiKey -> DebridServiceCredential(provider, apiKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun configuredResolverServices(settings: DebridSettings): List<DebridServiceCredential> =
|
||||||
|
configuredServices(settings).filter { credential ->
|
||||||
|
credential.provider.supports(DebridProviderCapability.ClientResolve) ||
|
||||||
|
credential.provider.supports(DebridProviderCapability.LocalTorrentResolve)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun preferredResolverService(settings: DebridSettings): DebridServiceCredential? {
|
||||||
|
val services = configuredResolverServices(settings)
|
||||||
|
if (services.isEmpty()) return null
|
||||||
|
val preferredId = byId(settings.preferredResolverProviderId)?.id
|
||||||
|
return services.firstOrNull { it.provider.id == preferredId } ?: services.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun configuredSourceNames(settings: DebridSettings): List<String> =
|
||||||
|
configuredServices(settings).map { instantName(it.provider.id) }
|
||||||
|
|
||||||
|
private fun String?.toFallbackDisplayName(): String {
|
||||||
|
val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return "Debrid"
|
||||||
|
return value
|
||||||
|
.replace('-', ' ')
|
||||||
|
.replace('_', ' ')
|
||||||
|
.split(' ')
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" ") { part ->
|
||||||
|
part.lowercase().replaceFirstChar { it.titlecase() }
|
||||||
|
}
|
||||||
|
.ifBlank { "Debrid" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun DebridProvider.supports(capability: DebridProviderCapability): Boolean =
|
||||||
|
capability in capabilities
|
||||||
|
|
@ -0,0 +1,422 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolve
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
internal interface DebridProviderApi {
|
||||||
|
val provider: DebridProvider
|
||||||
|
|
||||||
|
suspend fun validateApiKey(apiKey: String): Boolean
|
||||||
|
|
||||||
|
suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? = null
|
||||||
|
|
||||||
|
suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult =
|
||||||
|
DebridDeviceAuthorizationTokenResult.Unsupported
|
||||||
|
|
||||||
|
suspend fun resolveClientStream(
|
||||||
|
stream: StreamItem,
|
||||||
|
apiKey: String,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): DirectDebridResolveResult
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object DebridProviderApis {
|
||||||
|
private val registered = listOf(
|
||||||
|
TorboxDebridProviderApi(),
|
||||||
|
PremiumizeDebridProviderApi(),
|
||||||
|
RealDebridProviderApi(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun apiFor(providerId: String?): DebridProviderApi? {
|
||||||
|
val normalized = DebridProviders.byId(providerId)?.id ?: return null
|
||||||
|
return registered.firstOrNull { it.provider.id == normalized }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DebridDeviceAuthorization(
|
||||||
|
val providerId: String,
|
||||||
|
val deviceCode: String,
|
||||||
|
val userCode: String,
|
||||||
|
val verificationUrl: String,
|
||||||
|
val friendlyVerificationUrl: String,
|
||||||
|
val intervalSeconds: Int,
|
||||||
|
val expiresAt: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed interface DebridDeviceAuthorizationTokenResult {
|
||||||
|
data class Authorized(val accessToken: String) : DebridDeviceAuthorizationTokenResult
|
||||||
|
data object Pending : DebridDeviceAuthorizationTokenResult
|
||||||
|
data object Expired : DebridDeviceAuthorizationTokenResult
|
||||||
|
data object Unsupported : DebridDeviceAuthorizationTokenResult
|
||||||
|
data class Failed(val message: String?) : DebridDeviceAuthorizationTokenResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TorboxDebridProviderApi(
|
||||||
|
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
|
||||||
|
) : DebridProviderApi {
|
||||||
|
override val provider: DebridProvider = DebridProviders.Torbox
|
||||||
|
|
||||||
|
override suspend fun validateApiKey(apiKey: String): Boolean =
|
||||||
|
TorboxApiClient.validateApiKey(apiKey)
|
||||||
|
|
||||||
|
override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? {
|
||||||
|
val response = TorboxApiClient.startDeviceAuthorization(appName = appName)
|
||||||
|
val data = response.body?.takeIf { response.isSuccessful && it.success != false }?.data
|
||||||
|
?: return null
|
||||||
|
val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val userCode = data.code?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val verificationUrl = data.verificationUrl?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return DebridDeviceAuthorization(
|
||||||
|
providerId = provider.id,
|
||||||
|
deviceCode = deviceCode,
|
||||||
|
userCode = userCode,
|
||||||
|
verificationUrl = verificationUrl,
|
||||||
|
friendlyVerificationUrl = data.friendlyVerificationUrl?.takeIf { it.isNotBlank() }
|
||||||
|
?: verificationUrl,
|
||||||
|
intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5,
|
||||||
|
expiresAt = data.expiresAt?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult {
|
||||||
|
val normalized = deviceCode.trim()
|
||||||
|
if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null)
|
||||||
|
val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized)
|
||||||
|
return torboxDeviceAuthorizationTokenResult(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resolveClientStream(
|
||||||
|
stream: StreamItem,
|
||||||
|
apiKey: String,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): DirectDebridResolveResult {
|
||||||
|
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
|
||||||
|
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
|
||||||
|
?: buildMagnetUri(resolve)
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet)
|
||||||
|
val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId()
|
||||||
|
?: return create.toFailureForCreate()
|
||||||
|
|
||||||
|
val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId)
|
||||||
|
if (!torrent.isSuccessful) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val files = torrent.body?.data?.files.orEmpty()
|
||||||
|
val file = fileSelector.selectFile(files, resolve, season, episode)
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
val fileId = file.id ?: return DirectDebridResolveResult.Stale
|
||||||
|
|
||||||
|
val link = TorboxApiClient.requestDownloadLink(
|
||||||
|
apiKey = apiKey,
|
||||||
|
torrentId = torrentId,
|
||||||
|
fileId = fileId,
|
||||||
|
)
|
||||||
|
if (!link.isSuccessful) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val url = link.body?.data?.takeIf { it.isNotBlank() }
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
|
||||||
|
DirectDebridResolveResult.Success(
|
||||||
|
url = url,
|
||||||
|
filename = file.displayName().takeIf { it.isNotBlank() },
|
||||||
|
videoSize = file.size,
|
||||||
|
)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
DirectDebridResolveResult.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class PremiumizeDebridProviderApi(
|
||||||
|
private val fileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(),
|
||||||
|
private val clientIdProvider: () -> String = { PremiumizeConfig.CLIENT_ID },
|
||||||
|
) : DebridProviderApi {
|
||||||
|
override val provider: DebridProvider = DebridProviders.Premiumize
|
||||||
|
|
||||||
|
override suspend fun validateApiKey(apiKey: String): Boolean =
|
||||||
|
PremiumizeApiClient.validateApiKey(apiKey)
|
||||||
|
|
||||||
|
override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? {
|
||||||
|
val clientId = premiumizeClientIdOrThrow()
|
||||||
|
val response = PremiumizeApiClient.startDeviceAuthorization(clientId = clientId)
|
||||||
|
return premiumizeDeviceAuthorizationFromResponse(response, provider.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult {
|
||||||
|
val clientId = premiumizeClientIdOrThrow()
|
||||||
|
val normalized = deviceCode.trim()
|
||||||
|
if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null)
|
||||||
|
val response = PremiumizeApiClient.redeemDeviceAuthorization(
|
||||||
|
clientId = clientId,
|
||||||
|
deviceCode = normalized,
|
||||||
|
)
|
||||||
|
return premiumizeDeviceAuthorizationTokenResult(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resolveClientStream(
|
||||||
|
stream: StreamItem,
|
||||||
|
apiKey: String,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): DirectDebridResolveResult {
|
||||||
|
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
|
||||||
|
val source = resolve.magnetUri?.takeIf { it.isNotBlank() }
|
||||||
|
?: buildMagnetUri(resolve)
|
||||||
|
?: stream.playableDirectUrl?.takeIf { it.isNotBlank() }
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
return resolvePremiumizeDirectDownload(
|
||||||
|
apiKey = apiKey,
|
||||||
|
source = source,
|
||||||
|
resolve = resolve,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
fallbackFilename = stream.behaviorHints.filename,
|
||||||
|
fallbackSize = stream.behaviorHints.videoSize,
|
||||||
|
fileSelector = fileSelector,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun premiumizeClientIdOrThrow(): String =
|
||||||
|
clientIdProvider().trim().takeIf { it.isNotBlank() }
|
||||||
|
?: throw IllegalStateException("Premiumize sign-in is missing PREMIUMIZE_CLIENT_ID.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RealDebridProviderApi(
|
||||||
|
private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(),
|
||||||
|
) : DebridProviderApi {
|
||||||
|
override val provider: DebridProvider = DebridProviders.RealDebrid
|
||||||
|
|
||||||
|
override suspend fun validateApiKey(apiKey: String): Boolean =
|
||||||
|
RealDebridApiClient.validateApiKey(apiKey)
|
||||||
|
|
||||||
|
override suspend fun resolveClientStream(
|
||||||
|
stream: StreamItem,
|
||||||
|
apiKey: String,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): DirectDebridResolveResult {
|
||||||
|
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
|
||||||
|
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
|
||||||
|
?: buildMagnetUri(resolve)
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val add = RealDebridApiClient.addMagnet(apiKey, magnet)
|
||||||
|
val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() }
|
||||||
|
?: return add.toFailureForAdd()
|
||||||
|
var resolved = false
|
||||||
|
try {
|
||||||
|
val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
|
||||||
|
if (!infoBefore.isSuccessful) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val filesBefore = infoBefore.body?.files.orEmpty()
|
||||||
|
val file = fileSelector.selectFile(
|
||||||
|
files = filesBefore,
|
||||||
|
resolve = resolve,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
) ?: return DirectDebridResolveResult.Stale
|
||||||
|
val fileId = file.id ?: return DirectDebridResolveResult.Stale
|
||||||
|
val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString())
|
||||||
|
if (!select.isSuccessful && select.status != 202) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
|
||||||
|
val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
|
||||||
|
if (!infoAfter.isSuccessful) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val link = infoAfter.body?.firstDownloadLink()
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link)
|
||||||
|
if (!unrestrict.isSuccessful) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val url = unrestrict.body?.download?.takeIf { it.isNotBlank() }
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
resolved = true
|
||||||
|
DirectDebridResolveResult.Success(
|
||||||
|
url = url,
|
||||||
|
filename = unrestrict.body.filename?.takeIf { it.isNotBlank() }
|
||||||
|
?: file.displayName().takeIf { it.isNotBlank() },
|
||||||
|
videoSize = unrestrict.body.filesize ?: file.bytes,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (!resolved) {
|
||||||
|
runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
DirectDebridResolveResult.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMagnetUri(resolve: StreamClientResolve): String? {
|
||||||
|
val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return buildString {
|
||||||
|
append("magnet:?xt=urn:btih:")
|
||||||
|
append(hash)
|
||||||
|
resolve.sources
|
||||||
|
.mapNotNull { it.toTrackerUrlOrNull() }
|
||||||
|
.distinct()
|
||||||
|
.forEach { source ->
|
||||||
|
append("&tr=")
|
||||||
|
append(encodePathSegment(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun premiumizeDeviceAuthorizationFromResponse(
|
||||||
|
response: DebridApiResponse<PremiumizeDeviceAuthorizationDto>,
|
||||||
|
providerId: String,
|
||||||
|
): DebridDeviceAuthorization? {
|
||||||
|
val data = response.body?.takeIf { response.isSuccessful } ?: return null
|
||||||
|
val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val userCode = data.userCode?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val verificationUrl = data.verificationUri?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return DebridDeviceAuthorization(
|
||||||
|
providerId = providerId,
|
||||||
|
deviceCode = deviceCode,
|
||||||
|
userCode = userCode,
|
||||||
|
verificationUrl = verificationUrl,
|
||||||
|
friendlyVerificationUrl = data.verificationUriComplete?.takeIf { it.isNotBlank() }
|
||||||
|
?: verificationUrl,
|
||||||
|
intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5,
|
||||||
|
expiresAt = data.expiresIn?.takeIf { it > 0 }?.let { "${it}s" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun torboxDeviceAuthorizationTokenResult(
|
||||||
|
response: DebridApiResponse<TorboxEnvelopeDto<TorboxDeviceTokenDto>>,
|
||||||
|
): DebridDeviceAuthorizationTokenResult {
|
||||||
|
val envelope = response.body
|
||||||
|
val accessToken = envelope
|
||||||
|
?.takeIf { response.isSuccessful && it.success != false }
|
||||||
|
?.data
|
||||||
|
?.accessToken
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
if (accessToken != null) {
|
||||||
|
return DebridDeviceAuthorizationTokenResult.Authorized(accessToken)
|
||||||
|
}
|
||||||
|
val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody)
|
||||||
|
.joinToString(" ")
|
||||||
|
.lowercase()
|
||||||
|
return when {
|
||||||
|
message.contains("pending") ||
|
||||||
|
message.contains("not authorized") ||
|
||||||
|
message.contains("not been used") ||
|
||||||
|
message.contains("not used yet") ||
|
||||||
|
message.contains("scan the code") ->
|
||||||
|
DebridDeviceAuthorizationTokenResult.Pending
|
||||||
|
message.contains("expired") ->
|
||||||
|
DebridDeviceAuthorizationTokenResult.Expired
|
||||||
|
response.status == 404 || response.status == 409 || response.status == 425 ->
|
||||||
|
DebridDeviceAuthorizationTokenResult.Pending
|
||||||
|
response.status == 410 ->
|
||||||
|
DebridDeviceAuthorizationTokenResult.Expired
|
||||||
|
else ->
|
||||||
|
DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun premiumizeDeviceAuthorizationTokenResult(
|
||||||
|
response: DebridApiResponse<PremiumizeDeviceTokenDto>,
|
||||||
|
): DebridDeviceAuthorizationTokenResult {
|
||||||
|
val body = response.body
|
||||||
|
body?.accessToken?.takeIf { response.isSuccessful && it.isNotBlank() }?.let { accessToken ->
|
||||||
|
return DebridDeviceAuthorizationTokenResult.Authorized(accessToken)
|
||||||
|
}
|
||||||
|
return when (body?.error?.lowercase()) {
|
||||||
|
"authorization_pending", "slow_down" -> DebridDeviceAuthorizationTokenResult.Pending
|
||||||
|
"invalid_grant", "expired_token" -> DebridDeviceAuthorizationTokenResult.Expired
|
||||||
|
"access_denied" -> DebridDeviceAuthorizationTokenResult.Failed(body.errorDescription)
|
||||||
|
else -> {
|
||||||
|
if (response.status == 400 && body?.error.isNullOrBlank()) {
|
||||||
|
DebridDeviceAuthorizationTokenResult.Pending
|
||||||
|
} else {
|
||||||
|
DebridDeviceAuthorizationTokenResult.Failed(body?.errorDescription ?: body?.error ?: response.rawBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun resolvePremiumizeDirectDownload(
|
||||||
|
apiKey: String,
|
||||||
|
source: String,
|
||||||
|
resolve: StreamClientResolve,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
fallbackFilename: String? = null,
|
||||||
|
fallbackSize: Long? = null,
|
||||||
|
fileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(),
|
||||||
|
): DirectDebridResolveResult {
|
||||||
|
val normalizedSource = source.trim().takeIf { it.isNotBlank() } ?: return DirectDebridResolveResult.Stale
|
||||||
|
return try {
|
||||||
|
val response = PremiumizeApiClient.directDownload(apiKey = apiKey, source = normalizedSource)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return when (response.status) {
|
||||||
|
401, 403 -> DirectDebridResolveResult.Error
|
||||||
|
else -> DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = response.body ?: return DirectDebridResolveResult.Stale
|
||||||
|
if (body.status.equals("error", ignoreCase = true)) {
|
||||||
|
val message = listOfNotNull(body.message, body.code).joinToString(" ").lowercase()
|
||||||
|
return if (message.contains("cache") || message.contains("not found")) {
|
||||||
|
DirectDebridResolveResult.NotCached
|
||||||
|
} else {
|
||||||
|
DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val file = fileSelector.selectFile(
|
||||||
|
files = body.content.orEmpty(),
|
||||||
|
resolve = resolve,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
) ?: return DirectDebridResolveResult.Stale
|
||||||
|
val url = file.link?.takeIf { it.isNotBlank() } ?: return DirectDebridResolveResult.Stale
|
||||||
|
DirectDebridResolveResult.Success(
|
||||||
|
url = url,
|
||||||
|
filename = file.displayName().takeIf { it.isNotBlank() } ?: fallbackFilename,
|
||||||
|
videoSize = file.size ?: fallbackSize,
|
||||||
|
)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
DirectDebridResolveResult.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toTrackerUrlOrNull(): String? {
|
||||||
|
val value = trim()
|
||||||
|
if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null
|
||||||
|
return value.removePrefix("tracker:").trim().takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DebridApiResponse<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.toFailureForCreate(): DirectDebridResolveResult =
|
||||||
|
when (status) {
|
||||||
|
401, 403 -> DirectDebridResolveResult.Error
|
||||||
|
409 -> DirectDebridResolveResult.NotCached
|
||||||
|
else -> DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DebridApiResponse<RealDebridAddTorrentDto>.toFailureForAdd(): DirectDebridResolveResult =
|
||||||
|
when (status) {
|
||||||
|
401, 403 -> DirectDebridResolveResult.Error
|
||||||
|
else -> DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RealDebridTorrentInfoDto.firstDownloadLink(): String? {
|
||||||
|
if (!status.equals("downloaded", ignoreCase = true)) return null
|
||||||
|
return links.orEmpty().firstOrNull { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
data class DebridSettings(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val cloudLibraryEnabled: Boolean = true,
|
||||||
|
val providerApiKeys: Map<String, String> = emptyMap(),
|
||||||
|
val preferredResolverProviderId: String = "",
|
||||||
|
val instantPlaybackPreparationLimit: Int = 0,
|
||||||
|
val streamMaxResults: Int = 0,
|
||||||
|
val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT,
|
||||||
|
val streamMinimumQuality: DebridStreamMinimumQuality = DebridStreamMinimumQuality.ANY,
|
||||||
|
val streamDolbyVisionFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY,
|
||||||
|
val streamHdrFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY,
|
||||||
|
val streamCodecFilter: DebridStreamCodecFilter = DebridStreamCodecFilter.ANY,
|
||||||
|
val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(),
|
||||||
|
val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
|
||||||
|
val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
|
||||||
|
) {
|
||||||
|
val torboxApiKey: String
|
||||||
|
get() = apiKeyFor(DebridProviders.TORBOX_ID)
|
||||||
|
|
||||||
|
val realDebridApiKey: String
|
||||||
|
get() = apiKeyFor(DebridProviders.REAL_DEBRID_ID)
|
||||||
|
|
||||||
|
val premiumizeApiKey: String
|
||||||
|
get() = apiKeyFor(DebridProviders.PREMIUMIZE_ID)
|
||||||
|
|
||||||
|
val hasAnyApiKey: Boolean
|
||||||
|
get() = DebridProviders.configuredServices(this).isNotEmpty()
|
||||||
|
|
||||||
|
val resolverServices: List<DebridServiceCredential>
|
||||||
|
get() = DebridProviders.configuredResolverServices(this)
|
||||||
|
|
||||||
|
val activeResolverCredential: DebridServiceCredential?
|
||||||
|
get() = DebridProviders.preferredResolverService(this)
|
||||||
|
|
||||||
|
val activeResolverProviderId: String?
|
||||||
|
get() = activeResolverCredential?.provider?.id
|
||||||
|
|
||||||
|
val hasResolverProvider: Boolean
|
||||||
|
get() = activeResolverCredential != null
|
||||||
|
|
||||||
|
val linkResolvingEnabled: Boolean
|
||||||
|
get() = enabled
|
||||||
|
|
||||||
|
val canResolvePlayableLinks: Boolean
|
||||||
|
get() = linkResolvingEnabled && hasResolverProvider
|
||||||
|
|
||||||
|
val hasCloudLibraryProvider: Boolean
|
||||||
|
get() = DebridProviders.configuredServices(this)
|
||||||
|
.any { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
|
||||||
|
|
||||||
|
val canUseCloudLibrary: Boolean
|
||||||
|
get() = cloudLibraryEnabled && hasCloudLibraryProvider
|
||||||
|
|
||||||
|
val hasCustomStreamFormatting: Boolean
|
||||||
|
get() = DebridStreamFormatterDefaults.NAME_TEMPLATE.isNotBlank() ||
|
||||||
|
DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE.isNotBlank() ||
|
||||||
|
streamNameTemplate.isNotBlank() ||
|
||||||
|
streamDescriptionTemplate.isNotBlank()
|
||||||
|
|
||||||
|
fun apiKeyFor(providerId: String?): String {
|
||||||
|
val normalized = DebridProviders.byId(providerId)?.id
|
||||||
|
?: providerId?.trim()?.lowercase()
|
||||||
|
?: return ""
|
||||||
|
return providerApiKeys[normalized].orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2
|
||||||
|
const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5
|
||||||
|
|
||||||
|
enum class DebridStreamSortMode {
|
||||||
|
DEFAULT,
|
||||||
|
QUALITY_DESC,
|
||||||
|
SIZE_DESC,
|
||||||
|
SIZE_ASC,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DebridStreamMinimumQuality(val minResolution: Int) {
|
||||||
|
ANY(0),
|
||||||
|
P720(720),
|
||||||
|
P1080(1080),
|
||||||
|
P2160(2160),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DebridStreamFeatureFilter {
|
||||||
|
ANY,
|
||||||
|
EXCLUDE,
|
||||||
|
ONLY,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DebridStreamCodecFilter {
|
||||||
|
ANY,
|
||||||
|
H264,
|
||||||
|
HEVC,
|
||||||
|
AV1,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DebridStreamPreferences(
|
||||||
|
val maxResults: Int = 0,
|
||||||
|
val maxPerResolution: Int = 0,
|
||||||
|
val maxPerQuality: Int = 0,
|
||||||
|
val sizeMinGb: Int = 0,
|
||||||
|
val sizeMaxGb: Int = 0,
|
||||||
|
val preferredResolutions: List<DebridStreamResolution> = DebridStreamResolution.defaultOrder,
|
||||||
|
val requiredResolutions: List<DebridStreamResolution> = emptyList(),
|
||||||
|
val excludedResolutions: List<DebridStreamResolution> = emptyList(),
|
||||||
|
val preferredQualities: List<DebridStreamQuality> = DebridStreamQuality.defaultOrder,
|
||||||
|
val requiredQualities: List<DebridStreamQuality> = emptyList(),
|
||||||
|
val excludedQualities: List<DebridStreamQuality> = emptyList(),
|
||||||
|
val preferredVisualTags: List<DebridStreamVisualTag> = DebridStreamVisualTag.defaultOrder,
|
||||||
|
val requiredVisualTags: List<DebridStreamVisualTag> = emptyList(),
|
||||||
|
val excludedVisualTags: List<DebridStreamVisualTag> = emptyList(),
|
||||||
|
val preferredAudioTags: List<DebridStreamAudioTag> = DebridStreamAudioTag.defaultOrder,
|
||||||
|
val requiredAudioTags: List<DebridStreamAudioTag> = emptyList(),
|
||||||
|
val excludedAudioTags: List<DebridStreamAudioTag> = emptyList(),
|
||||||
|
val preferredAudioChannels: List<DebridStreamAudioChannel> = DebridStreamAudioChannel.defaultOrder,
|
||||||
|
val requiredAudioChannels: List<DebridStreamAudioChannel> = emptyList(),
|
||||||
|
val excludedAudioChannels: List<DebridStreamAudioChannel> = emptyList(),
|
||||||
|
val preferredEncodes: List<DebridStreamEncode> = DebridStreamEncode.defaultOrder,
|
||||||
|
val requiredEncodes: List<DebridStreamEncode> = emptyList(),
|
||||||
|
val excludedEncodes: List<DebridStreamEncode> = emptyList(),
|
||||||
|
val preferredLanguages: List<DebridStreamLanguage> = emptyList(),
|
||||||
|
val requiredLanguages: List<DebridStreamLanguage> = emptyList(),
|
||||||
|
val excludedLanguages: List<DebridStreamLanguage> = emptyList(),
|
||||||
|
val requiredReleaseGroups: List<String> = emptyList(),
|
||||||
|
val excludedReleaseGroups: List<String> = emptyList(),
|
||||||
|
val sortCriteria: List<DebridStreamSortCriterion> = DebridStreamSortCriterion.defaultOrder,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamResolution(val label: String, val value: Int) {
|
||||||
|
P2160("2160p", 2160),
|
||||||
|
P1440("1440p", 1440),
|
||||||
|
P1080("1080p", 1080),
|
||||||
|
P720("720p", 720),
|
||||||
|
P576("576p", 576),
|
||||||
|
P480("480p", 480),
|
||||||
|
P360("360p", 360),
|
||||||
|
UNKNOWN("Unknown", 0);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamQuality(val label: String) {
|
||||||
|
BLURAY_REMUX("BluRay REMUX"),
|
||||||
|
BLURAY("BluRay"),
|
||||||
|
WEB_DL("WEB-DL"),
|
||||||
|
WEBRIP("WEBRip"),
|
||||||
|
HDRIP("HDRip"),
|
||||||
|
HD_RIP("HC HD-Rip"),
|
||||||
|
DVDRIP("DVDRip"),
|
||||||
|
HDTV("HDTV"),
|
||||||
|
CAM("CAM"),
|
||||||
|
TS("TS"),
|
||||||
|
TC("TC"),
|
||||||
|
SCR("SCR"),
|
||||||
|
UNKNOWN("Unknown");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamVisualTag(val label: String) {
|
||||||
|
HDR_DV("HDR+DV"),
|
||||||
|
DV_ONLY("DV Only"),
|
||||||
|
HDR_ONLY("HDR Only"),
|
||||||
|
HDR10_PLUS("HDR10+"),
|
||||||
|
HDR10("HDR10"),
|
||||||
|
DV("DV"),
|
||||||
|
HDR("HDR"),
|
||||||
|
HLG("HLG"),
|
||||||
|
TEN_BIT("10bit"),
|
||||||
|
THREE_D("3D"),
|
||||||
|
IMAX("IMAX"),
|
||||||
|
AI("AI"),
|
||||||
|
SDR("SDR"),
|
||||||
|
H_OU("H-OU"),
|
||||||
|
H_SBS("H-SBS"),
|
||||||
|
UNKNOWN("Unknown");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamAudioTag(val label: String) {
|
||||||
|
ATMOS("Atmos"),
|
||||||
|
DD_PLUS("DD+"),
|
||||||
|
DD("DD"),
|
||||||
|
DTS_X("DTS:X"),
|
||||||
|
DTS_HD_MA("DTS-HD MA"),
|
||||||
|
DTS_HD("DTS-HD"),
|
||||||
|
DTS_ES("DTS-ES"),
|
||||||
|
DTS("DTS"),
|
||||||
|
TRUEHD("TrueHD"),
|
||||||
|
OPUS("OPUS"),
|
||||||
|
FLAC("FLAC"),
|
||||||
|
AAC("AAC"),
|
||||||
|
UNKNOWN("Unknown");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamAudioChannel(val label: String) {
|
||||||
|
CH_2_0("2.0"),
|
||||||
|
CH_5_1("5.1"),
|
||||||
|
CH_6_1("6.1"),
|
||||||
|
CH_7_1("7.1"),
|
||||||
|
UNKNOWN("Unknown");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamEncode(val label: String) {
|
||||||
|
AV1("AV1"),
|
||||||
|
HEVC("HEVC"),
|
||||||
|
AVC("AVC"),
|
||||||
|
XVID("XviD"),
|
||||||
|
DIVX("DivX"),
|
||||||
|
UNKNOWN("Unknown");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamLanguage(val code: String, val label: String) {
|
||||||
|
EN("en", "English"),
|
||||||
|
HI("hi", "Hindi"),
|
||||||
|
IT("it", "Italian"),
|
||||||
|
ES("es", "Spanish"),
|
||||||
|
FR("fr", "French"),
|
||||||
|
DE("de", "German"),
|
||||||
|
PT("pt", "Portuguese"),
|
||||||
|
PL("pl", "Polish"),
|
||||||
|
CS("cs", "Czech"),
|
||||||
|
LA("la", "Latino"),
|
||||||
|
JA("ja", "Japanese"),
|
||||||
|
KO("ko", "Korean"),
|
||||||
|
ZH("zh", "Chinese"),
|
||||||
|
MULTI("multi", "Multi"),
|
||||||
|
UNKNOWN("unknown", "Unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DebridStreamSortCriterion(
|
||||||
|
val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION,
|
||||||
|
val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val defaultOrder = listOf(
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamSortKey(val label: String) {
|
||||||
|
RESOLUTION("Resolution"),
|
||||||
|
QUALITY("Quality"),
|
||||||
|
VISUAL_TAG("Visual tag"),
|
||||||
|
AUDIO_TAG("Audio"),
|
||||||
|
AUDIO_CHANNEL("Audio channel"),
|
||||||
|
ENCODE("Encode"),
|
||||||
|
SIZE("Size"),
|
||||||
|
LANGUAGE("Language"),
|
||||||
|
RELEASE_GROUP("Release group"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DebridStreamSortDirection {
|
||||||
|
ASC,
|
||||||
|
DESC,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int =
|
||||||
|
value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT)
|
||||||
|
|
||||||
|
fun normalizeDebridStreamMaxResults(value: Int): Int =
|
||||||
|
if (value <= 0) 0 else value.coerceIn(1, 100)
|
||||||
|
|
@ -0,0 +1,512 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object DebridSettingsRepository {
|
||||||
|
private val _uiState = MutableStateFlow(DebridSettings())
|
||||||
|
val uiState: StateFlow<DebridSettings> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasLoaded = false
|
||||||
|
private var enabled = false
|
||||||
|
private var cloudLibraryEnabled = true
|
||||||
|
private var providerApiKeys = emptyMap<String, String>()
|
||||||
|
private var preferredResolverProviderId = ""
|
||||||
|
private var instantPlaybackPreparationLimit = 0
|
||||||
|
private var streamMaxResults = 0
|
||||||
|
private var streamSortMode = DebridStreamSortMode.DEFAULT
|
||||||
|
private var streamMinimumQuality = DebridStreamMinimumQuality.ANY
|
||||||
|
private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY
|
||||||
|
private var streamHdrFilter = DebridStreamFeatureFilter.ANY
|
||||||
|
private var streamCodecFilter = DebridStreamCodecFilter.ANY
|
||||||
|
private var streamPreferences = DebridStreamPreferences()
|
||||||
|
private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
|
||||||
|
private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
|
||||||
|
|
||||||
|
fun ensureLoaded() {
|
||||||
|
if (hasLoaded) return
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onProfileChanged() {
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): DebridSettings {
|
||||||
|
ensureLoaded()
|
||||||
|
return _uiState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(value: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (value && !hasResolverProvider()) return
|
||||||
|
if (enabled == value) return
|
||||||
|
enabled = value
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLinkResolvingEnabled(value: Boolean) {
|
||||||
|
setEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCloudLibraryEnabled(value: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (value && !hasCloudLibraryProvider()) return
|
||||||
|
if (cloudLibraryEnabled == value) return
|
||||||
|
cloudLibraryEnabled = value
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveCloudLibraryEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setProviderApiKey(providerId: String, value: String) {
|
||||||
|
ensureLoaded()
|
||||||
|
val provider = DebridProviders.byId(providerId) ?: return
|
||||||
|
val normalized = value.trim()
|
||||||
|
if (providerApiKeys[provider.id].orEmpty() == normalized) return
|
||||||
|
providerApiKeys = if (normalized.isBlank()) {
|
||||||
|
providerApiKeys - provider.id
|
||||||
|
} else {
|
||||||
|
providerApiKeys + (provider.id to normalized)
|
||||||
|
}
|
||||||
|
normalizePreferredResolverProviderId(save = true)
|
||||||
|
disableIfNoResolver()
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveProviderApiKey(provider.id, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTorboxApiKey(value: String) {
|
||||||
|
setProviderApiKey(DebridProviders.TORBOX_ID, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRealDebridApiKey(value: String) {
|
||||||
|
setProviderApiKey(DebridProviders.REAL_DEBRID_ID, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPremiumizeApiKey(value: String) {
|
||||||
|
setProviderApiKey(DebridProviders.PREMIUMIZE_ID, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPreferredResolverProviderId(providerId: String) {
|
||||||
|
ensureLoaded()
|
||||||
|
val normalized = DebridProviders.byId(providerId)?.id.orEmpty()
|
||||||
|
val next = connectedResolverProviderIds()
|
||||||
|
.firstOrNull { it == normalized }
|
||||||
|
?: connectedResolverProviderIds().firstOrNull().orEmpty()
|
||||||
|
if (preferredResolverProviderId == next) return
|
||||||
|
preferredResolverProviderId = next
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.savePreferredResolverProviderId(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInstantPlaybackPreparationLimit(value: Int) {
|
||||||
|
ensureLoaded()
|
||||||
|
val normalized = normalizeDebridInstantPlaybackPreparationLimit(value)
|
||||||
|
if (instantPlaybackPreparationLimit == normalized) return
|
||||||
|
instantPlaybackPreparationLimit = normalized
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamMaxResults(value: Int) {
|
||||||
|
ensureLoaded()
|
||||||
|
val normalized = normalizeDebridStreamMaxResults(value)
|
||||||
|
if (streamMaxResults == normalized && streamPreferences.maxResults == normalized) return
|
||||||
|
streamMaxResults = normalized
|
||||||
|
streamPreferences = streamPreferences.copy(maxResults = normalized).normalized()
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamMaxResults(normalized)
|
||||||
|
saveStreamPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamSortMode(value: DebridStreamSortMode) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (streamSortMode == value && streamPreferences.sortCriteria == sortCriteriaForLegacyMode(value)) return
|
||||||
|
streamSortMode = value
|
||||||
|
streamPreferences = streamPreferences.copy(sortCriteria = sortCriteriaForLegacyMode(value)).normalized()
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamSortMode(value.name)
|
||||||
|
saveStreamPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamMinimumQuality(value: DebridStreamMinimumQuality) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (streamMinimumQuality == value && streamPreferences.requiredResolutions == resolutionsForMinimumQuality(value)) return
|
||||||
|
streamMinimumQuality = value
|
||||||
|
streamPreferences = streamPreferences.copy(requiredResolutions = resolutionsForMinimumQuality(value)).normalized()
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamMinimumQuality(value.name)
|
||||||
|
saveStreamPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamDolbyVisionFilter(value: DebridStreamFeatureFilter) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (streamDolbyVisionFilter == value) return
|
||||||
|
streamDolbyVisionFilter = value
|
||||||
|
streamPreferences = streamPreferences.applyDolbyVisionFilter(value).normalized()
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamDolbyVisionFilter(value.name)
|
||||||
|
saveStreamPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamHdrFilter(value: DebridStreamFeatureFilter) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (streamHdrFilter == value) return
|
||||||
|
streamHdrFilter = value
|
||||||
|
streamPreferences = streamPreferences.applyHdrFilter(value).normalized()
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamHdrFilter(value.name)
|
||||||
|
saveStreamPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamCodecFilter(value: DebridStreamCodecFilter) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (streamCodecFilter == value) return
|
||||||
|
streamCodecFilter = value
|
||||||
|
streamPreferences = streamPreferences.applyCodecFilter(value).normalized()
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamCodecFilter(value.name)
|
||||||
|
saveStreamPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamPreferences(value: DebridStreamPreferences) {
|
||||||
|
ensureLoaded()
|
||||||
|
val normalized = value.normalized()
|
||||||
|
if (streamPreferences == normalized) return
|
||||||
|
streamPreferences = normalized
|
||||||
|
streamMaxResults = normalized.maxResults
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamMaxResults(streamMaxResults)
|
||||||
|
saveStreamPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamNameTemplate(value: String) {
|
||||||
|
ensureLoaded()
|
||||||
|
val normalized = normalizeStreamTemplate(value, DebridTemplateKind.NAME)
|
||||||
|
if (streamNameTemplate == normalized) return
|
||||||
|
streamNameTemplate = normalized
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamNameTemplate(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamDescriptionTemplate(value: String) {
|
||||||
|
ensureLoaded()
|
||||||
|
val normalized = normalizeStreamTemplate(value, DebridTemplateKind.DESCRIPTION)
|
||||||
|
if (streamDescriptionTemplate == normalized) return
|
||||||
|
streamDescriptionTemplate = normalized
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamDescriptionTemplate(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) {
|
||||||
|
ensureLoaded()
|
||||||
|
streamNameTemplate = normalizeStreamTemplate(nameTemplate, DebridTemplateKind.NAME)
|
||||||
|
streamDescriptionTemplate = normalizeStreamTemplate(descriptionTemplate, DebridTemplateKind.DESCRIPTION)
|
||||||
|
publish()
|
||||||
|
DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate)
|
||||||
|
DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetStreamTemplates() {
|
||||||
|
setStreamTemplates(
|
||||||
|
nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE,
|
||||||
|
descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableIfNoResolver() {
|
||||||
|
if (!hasResolverProvider()) {
|
||||||
|
enabled = false
|
||||||
|
DebridSettingsStorage.saveEnabled(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasCloudLibraryProvider(): Boolean =
|
||||||
|
DebridProviders.visible().any { provider ->
|
||||||
|
provider.supports(DebridProviderCapability.CloudLibrary) &&
|
||||||
|
providerApiKeys[provider.id].orEmpty().isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasResolverProvider(): Boolean = connectedResolverProviderIds().isNotEmpty()
|
||||||
|
|
||||||
|
private fun connectedResolverProviderIds(): List<String> =
|
||||||
|
DebridProviders.visible().filter { provider ->
|
||||||
|
(
|
||||||
|
provider.supports(DebridProviderCapability.ClientResolve) ||
|
||||||
|
provider.supports(DebridProviderCapability.LocalTorrentResolve)
|
||||||
|
) &&
|
||||||
|
providerApiKeys[provider.id].orEmpty().isNotBlank()
|
||||||
|
}.map { it.id }
|
||||||
|
|
||||||
|
private fun normalizePreferredResolverProviderId(save: Boolean = false) {
|
||||||
|
val providerId = DebridProviders.byId(preferredResolverProviderId)?.id.orEmpty()
|
||||||
|
val connectedResolverIds = connectedResolverProviderIds()
|
||||||
|
val normalized = if (providerId in connectedResolverIds) {
|
||||||
|
providerId
|
||||||
|
} else {
|
||||||
|
connectedResolverIds.firstOrNull().orEmpty()
|
||||||
|
}
|
||||||
|
if (preferredResolverProviderId != normalized) {
|
||||||
|
preferredResolverProviderId = normalized
|
||||||
|
if (save) {
|
||||||
|
DebridSettingsStorage.savePreferredResolverProviderId(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFromDisk() {
|
||||||
|
hasLoaded = true
|
||||||
|
providerApiKeys = DebridProviders.all()
|
||||||
|
.mapNotNull { provider ->
|
||||||
|
DebridSettingsStorage.loadProviderApiKey(provider.id)
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { apiKey -> provider.id to apiKey }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
preferredResolverProviderId = DebridSettingsStorage.loadPreferredResolverProviderId()
|
||||||
|
?.let(DebridProviders::byId)
|
||||||
|
?.id
|
||||||
|
.orEmpty()
|
||||||
|
normalizePreferredResolverProviderId(save = true)
|
||||||
|
enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasResolverProvider()
|
||||||
|
cloudLibraryEnabled = DebridSettingsStorage.loadCloudLibraryEnabled() ?: true
|
||||||
|
instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
|
||||||
|
DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
|
||||||
|
)
|
||||||
|
streamMaxResults = normalizeDebridStreamMaxResults(DebridSettingsStorage.loadStreamMaxResults() ?: 0)
|
||||||
|
streamSortMode = enumValueOrDefault(
|
||||||
|
DebridSettingsStorage.loadStreamSortMode(),
|
||||||
|
DebridStreamSortMode.DEFAULT,
|
||||||
|
)
|
||||||
|
streamMinimumQuality = enumValueOrDefault(
|
||||||
|
DebridSettingsStorage.loadStreamMinimumQuality(),
|
||||||
|
DebridStreamMinimumQuality.ANY,
|
||||||
|
)
|
||||||
|
streamDolbyVisionFilter = enumValueOrDefault(
|
||||||
|
DebridSettingsStorage.loadStreamDolbyVisionFilter(),
|
||||||
|
DebridStreamFeatureFilter.ANY,
|
||||||
|
)
|
||||||
|
streamHdrFilter = enumValueOrDefault(
|
||||||
|
DebridSettingsStorage.loadStreamHdrFilter(),
|
||||||
|
DebridStreamFeatureFilter.ANY,
|
||||||
|
)
|
||||||
|
streamCodecFilter = enumValueOrDefault(
|
||||||
|
DebridSettingsStorage.loadStreamCodecFilter(),
|
||||||
|
DebridStreamCodecFilter.ANY,
|
||||||
|
)
|
||||||
|
streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences())
|
||||||
|
?: legacyStreamPreferences(
|
||||||
|
maxResults = streamMaxResults,
|
||||||
|
sortMode = streamSortMode,
|
||||||
|
minimumQuality = streamMinimumQuality,
|
||||||
|
dolbyVisionFilter = streamDolbyVisionFilter,
|
||||||
|
hdrFilter = streamHdrFilter,
|
||||||
|
codecFilter = streamCodecFilter,
|
||||||
|
)
|
||||||
|
streamNameTemplate = normalizeStreamTemplate(
|
||||||
|
DebridSettingsStorage.loadStreamNameTemplate().orEmpty(),
|
||||||
|
DebridTemplateKind.NAME,
|
||||||
|
)
|
||||||
|
streamDescriptionTemplate = normalizeStreamTemplate(
|
||||||
|
DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(),
|
||||||
|
DebridTemplateKind.DESCRIPTION,
|
||||||
|
)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
_uiState.value = DebridSettings(
|
||||||
|
enabled = enabled,
|
||||||
|
cloudLibraryEnabled = cloudLibraryEnabled,
|
||||||
|
providerApiKeys = providerApiKeys,
|
||||||
|
preferredResolverProviderId = preferredResolverProviderId,
|
||||||
|
instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
|
||||||
|
streamMaxResults = streamMaxResults,
|
||||||
|
streamSortMode = streamSortMode,
|
||||||
|
streamMinimumQuality = streamMinimumQuality,
|
||||||
|
streamDolbyVisionFilter = streamDolbyVisionFilter,
|
||||||
|
streamHdrFilter = streamHdrFilter,
|
||||||
|
streamCodecFilter = streamCodecFilter,
|
||||||
|
streamPreferences = streamPreferences,
|
||||||
|
streamNameTemplate = streamNameTemplate,
|
||||||
|
streamDescriptionTemplate = streamDescriptionTemplate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveStreamPreferences() {
|
||||||
|
DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T : Enum<T>> enumValueOrDefault(value: String?, default: T): T =
|
||||||
|
runCatching { enumValueOf<T>(value.orEmpty()) }.getOrDefault(default)
|
||||||
|
|
||||||
|
private fun parseStreamPreferences(value: String?): DebridStreamPreferences? {
|
||||||
|
if (value.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
json.decodeFromString<DebridStreamPreferences>(value).normalized()
|
||||||
|
} catch (_: SerializationException) {
|
||||||
|
null
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class DebridTemplateKind {
|
||||||
|
NAME,
|
||||||
|
DESCRIPTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeStreamTemplate(value: String, kind: DebridTemplateKind): String {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
return when {
|
||||||
|
trimmed.isBlank() -> ""
|
||||||
|
kind == DebridTemplateKind.NAME && trimmed == DebridStreamFormatterDefaults.LEGACY_NAME_TEMPLATE -> ""
|
||||||
|
kind == DebridTemplateKind.DESCRIPTION && trimmed == DebridStreamFormatterDefaults.LEGACY_DESCRIPTION_TEMPLATE -> ""
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =
|
||||||
|
copy(
|
||||||
|
maxResults = normalizeDebridStreamMaxResults(maxResults),
|
||||||
|
maxPerResolution = maxPerResolution.coerceIn(0, 100),
|
||||||
|
maxPerQuality = maxPerQuality.coerceIn(0, 100),
|
||||||
|
sizeMinGb = sizeMinGb.coerceIn(0, 100),
|
||||||
|
sizeMaxGb = sizeMaxGb.coerceIn(0, 100),
|
||||||
|
preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder },
|
||||||
|
requiredResolutions = requiredResolutions,
|
||||||
|
excludedResolutions = excludedResolutions,
|
||||||
|
preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder },
|
||||||
|
requiredQualities = requiredQualities,
|
||||||
|
excludedQualities = excludedQualities,
|
||||||
|
preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder },
|
||||||
|
requiredVisualTags = requiredVisualTags,
|
||||||
|
excludedVisualTags = excludedVisualTags,
|
||||||
|
preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder },
|
||||||
|
requiredAudioTags = requiredAudioTags,
|
||||||
|
excludedAudioTags = excludedAudioTags,
|
||||||
|
preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder },
|
||||||
|
requiredAudioChannels = requiredAudioChannels,
|
||||||
|
excludedAudioChannels = excludedAudioChannels,
|
||||||
|
preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder },
|
||||||
|
requiredEncodes = requiredEncodes,
|
||||||
|
excludedEncodes = excludedEncodes,
|
||||||
|
preferredLanguages = preferredLanguages,
|
||||||
|
requiredLanguages = requiredLanguages,
|
||||||
|
excludedLanguages = excludedLanguages,
|
||||||
|
requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
||||||
|
excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
||||||
|
sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun legacyStreamPreferences(
|
||||||
|
maxResults: Int,
|
||||||
|
sortMode: DebridStreamSortMode,
|
||||||
|
minimumQuality: DebridStreamMinimumQuality,
|
||||||
|
dolbyVisionFilter: DebridStreamFeatureFilter,
|
||||||
|
hdrFilter: DebridStreamFeatureFilter,
|
||||||
|
codecFilter: DebridStreamCodecFilter,
|
||||||
|
): DebridStreamPreferences =
|
||||||
|
DebridStreamPreferences(
|
||||||
|
maxResults = normalizeDebridStreamMaxResults(maxResults),
|
||||||
|
sortCriteria = sortCriteriaForLegacyMode(sortMode),
|
||||||
|
requiredResolutions = resolutionsForMinimumQuality(minimumQuality),
|
||||||
|
)
|
||||||
|
.applyDolbyVisionFilter(dolbyVisionFilter)
|
||||||
|
.applyHdrFilter(hdrFilter)
|
||||||
|
.applyCodecFilter(codecFilter)
|
||||||
|
.normalized()
|
||||||
|
|
||||||
|
private fun DebridStreamPreferences.applyDolbyVisionFilter(
|
||||||
|
filter: DebridStreamFeatureFilter,
|
||||||
|
): DebridStreamPreferences =
|
||||||
|
when (filter) {
|
||||||
|
DebridStreamFeatureFilter.ANY -> copy(
|
||||||
|
requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(),
|
||||||
|
excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(),
|
||||||
|
)
|
||||||
|
DebridStreamFeatureFilter.EXCLUDE -> copy(
|
||||||
|
requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(),
|
||||||
|
excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(),
|
||||||
|
)
|
||||||
|
DebridStreamFeatureFilter.ONLY -> copy(
|
||||||
|
requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(),
|
||||||
|
excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DebridStreamPreferences.applyHdrFilter(
|
||||||
|
filter: DebridStreamFeatureFilter,
|
||||||
|
): DebridStreamPreferences =
|
||||||
|
when (filter) {
|
||||||
|
DebridStreamFeatureFilter.ANY -> copy(
|
||||||
|
requiredVisualTags = requiredVisualTags - hdrTags.toSet(),
|
||||||
|
excludedVisualTags = excludedVisualTags - hdrTags.toSet(),
|
||||||
|
)
|
||||||
|
DebridStreamFeatureFilter.EXCLUDE -> copy(
|
||||||
|
requiredVisualTags = requiredVisualTags - hdrTags.toSet(),
|
||||||
|
excludedVisualTags = (excludedVisualTags + hdrTags).distinct(),
|
||||||
|
)
|
||||||
|
DebridStreamFeatureFilter.ONLY -> copy(
|
||||||
|
requiredVisualTags = (requiredVisualTags + hdrTags).distinct(),
|
||||||
|
excludedVisualTags = excludedVisualTags - hdrTags.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DebridStreamPreferences.applyCodecFilter(
|
||||||
|
filter: DebridStreamCodecFilter,
|
||||||
|
): DebridStreamPreferences =
|
||||||
|
copy(
|
||||||
|
requiredEncodes = when (filter) {
|
||||||
|
DebridStreamCodecFilter.ANY -> emptyList()
|
||||||
|
DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC)
|
||||||
|
DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC)
|
||||||
|
DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List<DebridStreamResolution> =
|
||||||
|
DebridStreamResolution.defaultOrder.filter {
|
||||||
|
it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List<DebridStreamSortCriterion> =
|
||||||
|
when (mode) {
|
||||||
|
DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder
|
||||||
|
DebridStreamSortMode.QUALITY_DESC -> listOf(
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
|
||||||
|
)
|
||||||
|
DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC))
|
||||||
|
DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dolbyVisionTags = listOf(
|
||||||
|
DebridStreamVisualTag.DV,
|
||||||
|
DebridStreamVisualTag.DV_ONLY,
|
||||||
|
DebridStreamVisualTag.HDR_DV,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val hdrTags = listOf(
|
||||||
|
DebridStreamVisualTag.HDR,
|
||||||
|
DebridStreamVisualTag.HDR10,
|
||||||
|
DebridStreamVisualTag.HDR10_PLUS,
|
||||||
|
DebridStreamVisualTag.HLG,
|
||||||
|
DebridStreamVisualTag.HDR_ONLY,
|
||||||
|
DebridStreamVisualTag.HDR_DV,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
internal expect object DebridSettingsStorage {
|
||||||
|
fun loadEnabled(): Boolean?
|
||||||
|
fun saveEnabled(enabled: Boolean)
|
||||||
|
fun loadCloudLibraryEnabled(): Boolean?
|
||||||
|
fun saveCloudLibraryEnabled(enabled: Boolean)
|
||||||
|
fun loadPreferredResolverProviderId(): String?
|
||||||
|
fun savePreferredResolverProviderId(providerId: String)
|
||||||
|
fun loadProviderApiKey(providerId: String): String?
|
||||||
|
fun saveProviderApiKey(providerId: String, apiKey: String)
|
||||||
|
fun loadTorboxApiKey(): String?
|
||||||
|
fun saveTorboxApiKey(apiKey: String)
|
||||||
|
fun loadRealDebridApiKey(): String?
|
||||||
|
fun saveRealDebridApiKey(apiKey: String)
|
||||||
|
fun loadInstantPlaybackPreparationLimit(): Int?
|
||||||
|
fun saveInstantPlaybackPreparationLimit(limit: Int)
|
||||||
|
fun loadStreamMaxResults(): Int?
|
||||||
|
fun saveStreamMaxResults(maxResults: Int)
|
||||||
|
fun loadStreamSortMode(): String?
|
||||||
|
fun saveStreamSortMode(mode: String)
|
||||||
|
fun loadStreamMinimumQuality(): String?
|
||||||
|
fun saveStreamMinimumQuality(quality: String)
|
||||||
|
fun loadStreamDolbyVisionFilter(): String?
|
||||||
|
fun saveStreamDolbyVisionFilter(filter: String)
|
||||||
|
fun loadStreamHdrFilter(): String?
|
||||||
|
fun saveStreamHdrFilter(filter: String)
|
||||||
|
fun loadStreamCodecFilter(): String?
|
||||||
|
fun saveStreamCodecFilter(filter: String)
|
||||||
|
fun loadStreamPreferences(): String?
|
||||||
|
fun saveStreamPreferences(preferences: String)
|
||||||
|
fun loadStreamNameTemplate(): String?
|
||||||
|
fun saveStreamNameTemplate(template: String)
|
||||||
|
fun loadStreamDescriptionTemplate(): String?
|
||||||
|
fun saveStreamDescriptionTemplate(template: String)
|
||||||
|
fun exportToSyncPayload(): JsonObject
|
||||||
|
fun replaceFromSyncPayload(payload: JsonObject)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridStreamPresentation.isManagedDebridStream
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolve
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolveParsed
|
||||||
|
import com.nuvio.app.features.streams.StreamDebridCacheState
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
|
||||||
|
class DebridStreamFormatter(
|
||||||
|
private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(),
|
||||||
|
) {
|
||||||
|
fun format(stream: StreamItem, settings: DebridSettings): StreamItem {
|
||||||
|
if (!stream.isManagedDebridStream) return stream
|
||||||
|
val values = buildValues(stream, settings)
|
||||||
|
val nameTemplate = settings.streamNameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
|
||||||
|
val descriptionTemplate = settings.streamDescriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
|
||||||
|
val formattedName = engine.render(nameTemplate, values)
|
||||||
|
.lineSequence()
|
||||||
|
.joinToString(" ") { it.trim() }
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
.trim()
|
||||||
|
val formattedDescription = engine.render(descriptionTemplate, values)
|
||||||
|
.lineSequence()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString("\n")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return stream.copy(
|
||||||
|
name = formattedName.ifBlank { stream.name ?: DebridProviders.displayName(serviceId(stream)) },
|
||||||
|
description = formattedDescription.ifBlank { stream.description ?: stream.title },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildValues(stream: StreamItem, settings: DebridSettings): Map<String, Any?> {
|
||||||
|
val resolve = stream.clientResolve
|
||||||
|
val raw = resolve?.stream?.raw
|
||||||
|
val parsed = raw?.parsed
|
||||||
|
val facts = DebridStreamMetadata.facts(
|
||||||
|
stream = stream,
|
||||||
|
preferences = DebridStreamMetadata.effectivePreferences(settings),
|
||||||
|
)
|
||||||
|
val seasons = parsed?.seasons.orEmpty()
|
||||||
|
val episodes = parsed?.episodes.orEmpty()
|
||||||
|
val season = resolve?.season ?: seasons.singleOrFirstOrNull()
|
||||||
|
val episode = resolve?.episode ?: episodes.singleOrFirstOrNull()
|
||||||
|
val visualTags = facts.visualTags.mapNotUnknown { it.label }
|
||||||
|
val audioTags = facts.audioTags.mapNotUnknown { it.label }
|
||||||
|
val audioChannels = facts.audioChannels.mapNotUnknown { it.label }
|
||||||
|
val edition = parsed?.edition ?: buildEdition(parsed)
|
||||||
|
|
||||||
|
return linkedMapOf(
|
||||||
|
"stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title),
|
||||||
|
"stream.year" to parsed?.year,
|
||||||
|
"stream.season" to season,
|
||||||
|
"stream.episode" to episode,
|
||||||
|
"stream.seasons" to seasons,
|
||||||
|
"stream.episodes" to episodes,
|
||||||
|
"stream.seasonEpisode" to buildSeasonEpisodeList(season, episode, seasons, episodes),
|
||||||
|
"stream.formattedEpisodes" to formatEpisodes(episodes),
|
||||||
|
"stream.formattedSeasons" to formatSeasons(seasons),
|
||||||
|
"stream.resolution" to facts.resolution.labelUnlessUnknown(),
|
||||||
|
"stream.library" to false,
|
||||||
|
"stream.quality" to facts.quality.labelUnlessUnknown(),
|
||||||
|
"stream.visualTags" to visualTags,
|
||||||
|
"stream.audioTags" to audioTags,
|
||||||
|
"stream.audioChannels" to audioChannels,
|
||||||
|
"stream.languages" to languageValues(parsed, facts),
|
||||||
|
"stream.languageEmojis" to languageValues(parsed, facts).map { languageEmoji(it) },
|
||||||
|
"stream.size" to facts.size?.let(::DebridTemplateBytes),
|
||||||
|
"stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes),
|
||||||
|
"stream.encode" to facts.encode.labelUnlessUnknown(),
|
||||||
|
"stream.indexer" to (raw?.indexer ?: raw?.tracker ?: stream.sourceName),
|
||||||
|
"stream.network" to (parsed?.network ?: raw?.network),
|
||||||
|
"stream.releaseGroup" to facts.releaseGroup.takeIf { it.isNotBlank() },
|
||||||
|
"stream.duration" to parsed?.duration,
|
||||||
|
"stream.edition" to edition,
|
||||||
|
"stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename ?: stream.debridCacheStatus?.cachedName),
|
||||||
|
"stream.regexMatched" to null,
|
||||||
|
"stream.type" to streamType(stream, resolve),
|
||||||
|
"service.cached" to serviceCached(stream, resolve),
|
||||||
|
"service.shortName" to DebridProviders.shortName(serviceId(stream)),
|
||||||
|
"service.name" to DebridProviders.displayName(serviceId(stream)),
|
||||||
|
"addon.name" to stream.addonName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serviceId(stream: StreamItem): String? =
|
||||||
|
stream.debridCacheStatus?.providerId ?: stream.clientResolve?.service
|
||||||
|
|
||||||
|
private fun serviceCached(stream: StreamItem, resolve: StreamClientResolve?): Boolean? =
|
||||||
|
when (stream.debridCacheStatus?.state) {
|
||||||
|
StreamDebridCacheState.CACHED -> true
|
||||||
|
StreamDebridCacheState.NOT_CACHED -> false
|
||||||
|
StreamDebridCacheState.CHECKING,
|
||||||
|
StreamDebridCacheState.UNKNOWN,
|
||||||
|
null -> resolve?.isCached
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamType(stream: StreamItem, resolve: StreamClientResolve?): String =
|
||||||
|
when {
|
||||||
|
stream.debridCacheStatus != null -> "Debrid"
|
||||||
|
resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid"
|
||||||
|
resolve?.type.equals("torrent", ignoreCase = true) -> "p2p"
|
||||||
|
else -> resolve?.type.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEdition(parsed: StreamClientResolveParsed?): String? {
|
||||||
|
if (parsed == null) return null
|
||||||
|
return buildList {
|
||||||
|
if (parsed.extended == true) add("extended")
|
||||||
|
if (parsed.theatrical == true) add("theatrical")
|
||||||
|
if (parsed.remastered == true) add("remastered")
|
||||||
|
if (parsed.unrated == true) add("unrated")
|
||||||
|
}.joinToString(" ").takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSeasonEpisodeList(
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
seasons: List<Int>,
|
||||||
|
episodes: List<Int>,
|
||||||
|
): List<String> {
|
||||||
|
if (season != null && episode != null) return listOf("S${season.twoDigits()}E${episode.twoDigits()}")
|
||||||
|
if (seasons.isEmpty() || episodes.isEmpty()) return emptyList()
|
||||||
|
return seasons.flatMap { s -> episodes.map { e -> "S${s.twoDigits()}E${e.twoDigits()}" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatEpisodes(episodes: List<Int>): String =
|
||||||
|
episodes.joinToString(" | ") { "E${it.twoDigits()}" }
|
||||||
|
|
||||||
|
private fun formatSeasons(seasons: List<Int>): String =
|
||||||
|
seasons.joinToString(" | ") { "S${it.twoDigits()}" }
|
||||||
|
|
||||||
|
private fun List<Int>.singleOrFirstOrNull(): Int? =
|
||||||
|
singleOrNull() ?: firstOrNull()
|
||||||
|
|
||||||
|
private fun Int.twoDigits(): String = toString().padStart(2, '0')
|
||||||
|
|
||||||
|
private fun languageValues(parsed: StreamClientResolveParsed?, facts: DebridStreamFacts): List<String> =
|
||||||
|
parsed?.languages.orEmpty().ifEmpty { facts.languages.map { it.code } }
|
||||||
|
|
||||||
|
private fun languageEmoji(language: String): String =
|
||||||
|
when (language.lowercase()) {
|
||||||
|
"en", "eng", "english" -> "GB"
|
||||||
|
"hi", "hin", "hindi" -> "IN"
|
||||||
|
"ml", "mal", "malayalam" -> "IN"
|
||||||
|
"ta", "tam", "tamil" -> "IN"
|
||||||
|
"te", "tel", "telugu" -> "IN"
|
||||||
|
"ja", "jpn", "japanese" -> "JP"
|
||||||
|
"ko", "kor", "korean" -> "KR"
|
||||||
|
"fr", "fre", "fra", "french" -> "FR"
|
||||||
|
"es", "spa", "spanish" -> "ES"
|
||||||
|
"de", "ger", "deu", "german" -> "DE"
|
||||||
|
"it", "ita", "italian" -> "IT"
|
||||||
|
"multi" -> "Multi"
|
||||||
|
else -> language
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <T> List<T>.mapNotUnknown(label: (T) -> String): List<String> =
|
||||||
|
map(label).filterNot { it.equals("Unknown", ignoreCase = true) }
|
||||||
|
|
||||||
|
private fun DebridStreamResolution.labelUnlessUnknown(): String? =
|
||||||
|
label.takeUnless { this == DebridStreamResolution.UNKNOWN }
|
||||||
|
|
||||||
|
private fun DebridStreamQuality.labelUnlessUnknown(): String? =
|
||||||
|
label.takeUnless { this == DebridStreamQuality.UNKNOWN }
|
||||||
|
|
||||||
|
private fun DebridStreamEncode.labelUnlessUnknown(): String? =
|
||||||
|
label.takeUnless { this == DebridStreamEncode.UNKNOWN }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
object DebridStreamFormatterDefaults {
|
||||||
|
const val NAME_TEMPLATE = "{stream.resolution::exists[\"{stream.resolution} \"||\"\"]}{service.shortName::exists[\"{service.shortName}\"||\"Cloud\"]} Instant"
|
||||||
|
|
||||||
|
const val DESCRIPTION_TEMPLATE = ""
|
||||||
|
|
||||||
|
const val LEGACY_NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}"
|
||||||
|
|
||||||
|
const val LEGACY_DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,447 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||||
|
import com.nuvio.app.features.streams.StreamDebridCacheState
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
|
||||||
|
object DebridStreamPresentation {
|
||||||
|
private val formatter = DebridStreamFormatter()
|
||||||
|
|
||||||
|
fun apply(groups: List<AddonStreamGroup>, settings: DebridSettings): List<AddonStreamGroup> {
|
||||||
|
if (!settings.canResolvePlayableLinks) return groups
|
||||||
|
return groups.map { group ->
|
||||||
|
val visibleStreams = group.streams
|
||||||
|
.filterNot { stream -> stream.isInactiveResolverStream(settings) }
|
||||||
|
.filterNot { stream -> stream.isUncachedDebridStream }
|
||||||
|
val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream }
|
||||||
|
if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams)
|
||||||
|
|
||||||
|
val presentedDebridStreams = applyPreferences(debridStreams, settings)
|
||||||
|
.map { stream ->
|
||||||
|
if (settings.hasCustomStreamFormatting) {
|
||||||
|
formatter.format(stream, settings)
|
||||||
|
} else {
|
||||||
|
stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val passthroughStreams = visibleStreams.filterNot { stream -> stream.isManagedDebridStream }
|
||||||
|
|
||||||
|
group.copy(streams = presentedDebridStreams + passthroughStreams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun applyPreferences(streams: List<StreamItem>, settings: DebridSettings): List<StreamItem> {
|
||||||
|
val preferences = DebridStreamMetadata.effectivePreferences(settings)
|
||||||
|
return streams.map { it to DebridStreamMetadata.facts(it, preferences) }
|
||||||
|
.filter { (_, facts) -> facts.matchesFilters(preferences) }
|
||||||
|
.sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) }
|
||||||
|
.let { sorted -> applyLimits(sorted, preferences) }
|
||||||
|
.map { it.first }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val StreamItem.isManagedDebridStream: Boolean
|
||||||
|
get() {
|
||||||
|
val status = debridCacheStatus
|
||||||
|
return isAddonDebridCandidate && (isDirectDebridStream || (
|
||||||
|
isTorrentStream &&
|
||||||
|
status != null &&
|
||||||
|
DebridProviders.byId(status.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true &&
|
||||||
|
status.state != StreamDebridCacheState.CHECKING
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val StreamItem.isUncachedDebridStream: Boolean
|
||||||
|
get() = isInstalledAddonStream &&
|
||||||
|
DebridProviders.byId(debridCacheStatus?.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true &&
|
||||||
|
debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED
|
||||||
|
|
||||||
|
private fun StreamItem.isInactiveResolverStream(settings: DebridSettings): Boolean {
|
||||||
|
val streamProviderId = DebridProviders.byId(clientResolve?.service)?.id ?: return false
|
||||||
|
val activeProviderId = settings.activeResolverProviderId ?: return false
|
||||||
|
return isDirectDebridStream && streamProviderId != activeProviderId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyLimits(
|
||||||
|
streams: List<Pair<StreamItem, DebridStreamFacts>>,
|
||||||
|
preferences: DebridStreamPreferences,
|
||||||
|
): List<Pair<StreamItem, DebridStreamFacts>> {
|
||||||
|
val resolutionCounts = mutableMapOf<DebridStreamResolution, Int>()
|
||||||
|
val qualityCounts = mutableMapOf<DebridStreamQuality, Int>()
|
||||||
|
val result = mutableListOf<Pair<StreamItem, DebridStreamFacts>>()
|
||||||
|
for (stream in streams) {
|
||||||
|
if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break
|
||||||
|
if (preferences.maxPerResolution > 0) {
|
||||||
|
val count = resolutionCounts[stream.second.resolution] ?: 0
|
||||||
|
if (count >= preferences.maxPerResolution) continue
|
||||||
|
}
|
||||||
|
if (preferences.maxPerQuality > 0) {
|
||||||
|
val count = qualityCounts[stream.second.quality] ?: 0
|
||||||
|
if (count >= preferences.maxPerQuality) continue
|
||||||
|
}
|
||||||
|
resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1
|
||||||
|
qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1
|
||||||
|
result += stream
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DebridStreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean {
|
||||||
|
if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false
|
||||||
|
if (resolution in preferences.excludedResolutions) return false
|
||||||
|
if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false
|
||||||
|
if (quality in preferences.excludedQualities) return false
|
||||||
|
if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false
|
||||||
|
if (visualTags.any { it in preferences.excludedVisualTags }) return false
|
||||||
|
if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false
|
||||||
|
if (audioTags.any { it in preferences.excludedAudioTags }) return false
|
||||||
|
if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false
|
||||||
|
if (audioChannels.any { it in preferences.excludedAudioChannels }) return false
|
||||||
|
if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false
|
||||||
|
if (encode in preferences.excludedEncodes) return false
|
||||||
|
if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false
|
||||||
|
if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false
|
||||||
|
if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false
|
||||||
|
if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false
|
||||||
|
if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false
|
||||||
|
if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareFacts(
|
||||||
|
left: DebridStreamFacts,
|
||||||
|
right: DebridStreamFacts,
|
||||||
|
criteria: List<DebridStreamSortCriterion>,
|
||||||
|
): Int {
|
||||||
|
for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) {
|
||||||
|
val comparison = compareKey(left, right, criterion)
|
||||||
|
if (comparison != 0) return comparison
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareKey(
|
||||||
|
left: DebridStreamFacts,
|
||||||
|
right: DebridStreamFacts,
|
||||||
|
criterion: DebridStreamSortCriterion,
|
||||||
|
): Int {
|
||||||
|
val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1
|
||||||
|
return when (criterion.key) {
|
||||||
|
DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction
|
||||||
|
DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction
|
||||||
|
DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction
|
||||||
|
DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction
|
||||||
|
DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction
|
||||||
|
DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction
|
||||||
|
DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction
|
||||||
|
DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction
|
||||||
|
DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object DebridStreamMetadata {
|
||||||
|
fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences {
|
||||||
|
val default = DebridStreamPreferences()
|
||||||
|
if (settings.streamPreferences != default) return settings.streamPreferences.normalized()
|
||||||
|
if (
|
||||||
|
settings.streamMaxResults == 0 &&
|
||||||
|
settings.streamSortMode == DebridStreamSortMode.DEFAULT &&
|
||||||
|
settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY &&
|
||||||
|
settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY &&
|
||||||
|
settings.streamHdrFilter == DebridStreamFeatureFilter.ANY &&
|
||||||
|
settings.streamCodecFilter == DebridStreamCodecFilter.ANY
|
||||||
|
) {
|
||||||
|
return default
|
||||||
|
}
|
||||||
|
var preferences = default.copy(
|
||||||
|
maxResults = settings.streamMaxResults,
|
||||||
|
sortCriteria = when (settings.streamSortMode) {
|
||||||
|
DebridStreamSortMode.DEFAULT -> default.sortCriteria
|
||||||
|
DebridStreamSortMode.QUALITY_DESC -> listOf(
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
|
||||||
|
)
|
||||||
|
DebridStreamSortMode.SIZE_DESC -> listOf(
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
|
||||||
|
)
|
||||||
|
DebridStreamSortMode.SIZE_ASC -> listOf(
|
||||||
|
DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
requiredResolutions = DebridStreamResolution.defaultOrder.filter {
|
||||||
|
it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN
|
||||||
|
},
|
||||||
|
)
|
||||||
|
preferences = when (settings.streamDolbyVisionFilter) {
|
||||||
|
DebridStreamFeatureFilter.ANY -> preferences
|
||||||
|
DebridStreamFeatureFilter.EXCLUDE -> preferences.copy(
|
||||||
|
excludedVisualTags = preferences.excludedVisualTags + listOf(
|
||||||
|
DebridStreamVisualTag.DV,
|
||||||
|
DebridStreamVisualTag.DV_ONLY,
|
||||||
|
DebridStreamVisualTag.HDR_DV,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DebridStreamFeatureFilter.ONLY -> preferences.copy(
|
||||||
|
requiredVisualTags = preferences.requiredVisualTags + listOf(
|
||||||
|
DebridStreamVisualTag.DV,
|
||||||
|
DebridStreamVisualTag.DV_ONLY,
|
||||||
|
DebridStreamVisualTag.HDR_DV,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
preferences = when (settings.streamHdrFilter) {
|
||||||
|
DebridStreamFeatureFilter.ANY -> preferences
|
||||||
|
DebridStreamFeatureFilter.EXCLUDE -> preferences.copy(
|
||||||
|
excludedVisualTags = preferences.excludedVisualTags + listOf(
|
||||||
|
DebridStreamVisualTag.HDR,
|
||||||
|
DebridStreamVisualTag.HDR10,
|
||||||
|
DebridStreamVisualTag.HDR10_PLUS,
|
||||||
|
DebridStreamVisualTag.HLG,
|
||||||
|
DebridStreamVisualTag.HDR_ONLY,
|
||||||
|
DebridStreamVisualTag.HDR_DV,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DebridStreamFeatureFilter.ONLY -> preferences.copy(
|
||||||
|
requiredVisualTags = preferences.requiredVisualTags + listOf(
|
||||||
|
DebridStreamVisualTag.HDR,
|
||||||
|
DebridStreamVisualTag.HDR10,
|
||||||
|
DebridStreamVisualTag.HDR10_PLUS,
|
||||||
|
DebridStreamVisualTag.HLG,
|
||||||
|
DebridStreamVisualTag.HDR_ONLY,
|
||||||
|
DebridStreamVisualTag.HDR_DV,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return when (settings.streamCodecFilter) {
|
||||||
|
DebridStreamCodecFilter.ANY -> preferences
|
||||||
|
DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC))
|
||||||
|
DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC))
|
||||||
|
DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1))
|
||||||
|
}.normalized()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun facts(stream: StreamItem, preferences: DebridStreamPreferences): DebridStreamFacts {
|
||||||
|
val parsed = stream.clientResolve?.stream?.raw?.parsed
|
||||||
|
val searchText = streamSearchText(stream)
|
||||||
|
val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText)
|
||||||
|
val quality = streamQuality(parsed?.quality, searchText)
|
||||||
|
val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText)
|
||||||
|
val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText)
|
||||||
|
val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText)
|
||||||
|
val encode = streamEncode(parsed?.codec, searchText)
|
||||||
|
val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty {
|
||||||
|
DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) }
|
||||||
|
}
|
||||||
|
val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText)
|
||||||
|
return DebridStreamFacts(
|
||||||
|
resolution = resolution,
|
||||||
|
quality = quality,
|
||||||
|
visualTags = visualTags,
|
||||||
|
audioTags = audioTags,
|
||||||
|
audioChannels = audioChannels,
|
||||||
|
encode = encode,
|
||||||
|
languages = languages,
|
||||||
|
releaseGroup = releaseGroup,
|
||||||
|
size = streamSize(stream),
|
||||||
|
resolutionRank = rank(resolution, preferences.preferredResolutions),
|
||||||
|
qualityRank = rank(quality, preferences.preferredQualities),
|
||||||
|
visualRank = rankAny(visualTags, preferences.preferredVisualTags),
|
||||||
|
audioRank = rankAny(audioTags, preferences.preferredAudioTags),
|
||||||
|
channelRank = rankAny(audioChannels, preferences.preferredAudioChannels),
|
||||||
|
encodeRank = rank(encode, preferences.preferredEncodes),
|
||||||
|
languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamResolution(vararg values: String?): DebridStreamResolution =
|
||||||
|
values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN
|
||||||
|
|
||||||
|
private fun resolutionValue(value: String?): DebridStreamResolution? {
|
||||||
|
val normalized = value?.lowercase().orEmpty()
|
||||||
|
return when {
|
||||||
|
normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160
|
||||||
|
normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440
|
||||||
|
normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080
|
||||||
|
normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720
|
||||||
|
normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576
|
||||||
|
normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480
|
||||||
|
normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality {
|
||||||
|
val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase()
|
||||||
|
return when {
|
||||||
|
text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX
|
||||||
|
text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY
|
||||||
|
text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL
|
||||||
|
text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP
|
||||||
|
text.contains("hdrip") -> DebridStreamQuality.HDRIP
|
||||||
|
text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP
|
||||||
|
text.contains("dvdrip") -> DebridStreamQuality.DVDRIP
|
||||||
|
text.contains("hdtv") -> DebridStreamQuality.HDTV
|
||||||
|
text.hasToken("cam") -> DebridStreamQuality.CAM
|
||||||
|
text.hasToken("ts") -> DebridStreamQuality.TS
|
||||||
|
text.hasToken("tc") -> DebridStreamQuality.TC
|
||||||
|
text.hasToken("scr") -> DebridStreamQuality.SCR
|
||||||
|
else -> DebridStreamQuality.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamVisualTags(parsedHdr: List<String>, searchText: String): List<DebridStreamVisualTag> {
|
||||||
|
val text = (parsedHdr + searchText).joinToString(" ").lowercase()
|
||||||
|
val tags = mutableListOf<DebridStreamVisualTag>()
|
||||||
|
val hasDv = parsedHdr.any { it.isDolbyVisionToken() } ||
|
||||||
|
Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|\$)").containsMatchIn(searchText)
|
||||||
|
val hasHdr = parsedHdr.any { it.isHdrToken() } ||
|
||||||
|
Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|\$)").containsMatchIn(searchText)
|
||||||
|
if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV
|
||||||
|
if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY
|
||||||
|
if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY
|
||||||
|
if (text.contains("hdr10+") || text.contains("hdr10plus")) tags += DebridStreamVisualTag.HDR10_PLUS
|
||||||
|
if (text.contains("hdr10")) tags += DebridStreamVisualTag.HDR10
|
||||||
|
if (hasDv) tags += DebridStreamVisualTag.DV
|
||||||
|
if (hasHdr) tags += DebridStreamVisualTag.HDR
|
||||||
|
if (text.hasToken("hlg")) tags += DebridStreamVisualTag.HLG
|
||||||
|
if (text.contains("10bit") || text.contains("10 bit")) tags += DebridStreamVisualTag.TEN_BIT
|
||||||
|
if (text.hasToken("3d")) tags += DebridStreamVisualTag.THREE_D
|
||||||
|
if (text.hasToken("imax")) tags += DebridStreamVisualTag.IMAX
|
||||||
|
if (text.hasToken("ai")) tags += DebridStreamVisualTag.AI
|
||||||
|
if (text.hasToken("sdr")) tags += DebridStreamVisualTag.SDR
|
||||||
|
if (text.contains("h-ou")) tags += DebridStreamVisualTag.H_OU
|
||||||
|
if (text.contains("h-sbs")) tags += DebridStreamVisualTag.H_SBS
|
||||||
|
return tags.distinct().ifEmpty { listOf(DebridStreamVisualTag.UNKNOWN) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamAudioTags(parsedAudio: List<String>, searchText: String): List<DebridStreamAudioTag> {
|
||||||
|
val text = (parsedAudio + searchText).joinToString(" ").lowercase()
|
||||||
|
val tags = mutableListOf<DebridStreamAudioTag>()
|
||||||
|
if (text.hasToken("atmos")) tags += DebridStreamAudioTag.ATMOS
|
||||||
|
if (text.contains("dd+") || text.contains("ddp") || text.contains("dolby digital plus")) tags += DebridStreamAudioTag.DD_PLUS
|
||||||
|
if (text.hasToken("dd") || text.contains("ac3") || text.contains("dolby digital")) tags += DebridStreamAudioTag.DD
|
||||||
|
if (text.contains("dts:x") || text.contains("dtsx")) tags += DebridStreamAudioTag.DTS_X
|
||||||
|
if (text.contains("dts-hd ma") || text.contains("dtshd ma")) tags += DebridStreamAudioTag.DTS_HD_MA
|
||||||
|
if (text.contains("dts-hd") || text.contains("dtshd")) tags += DebridStreamAudioTag.DTS_HD
|
||||||
|
if (text.contains("dts-es") || text.contains("dtses")) tags += DebridStreamAudioTag.DTS_ES
|
||||||
|
if (text.hasToken("dts")) tags += DebridStreamAudioTag.DTS
|
||||||
|
if (text.contains("truehd") || text.contains("true hd")) tags += DebridStreamAudioTag.TRUEHD
|
||||||
|
if (text.hasToken("opus")) tags += DebridStreamAudioTag.OPUS
|
||||||
|
if (text.hasToken("flac")) tags += DebridStreamAudioTag.FLAC
|
||||||
|
if (text.hasToken("aac")) tags += DebridStreamAudioTag.AAC
|
||||||
|
return tags.distinct().ifEmpty { listOf(DebridStreamAudioTag.UNKNOWN) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamAudioChannels(parsedChannels: List<String>, searchText: String): List<DebridStreamAudioChannel> {
|
||||||
|
val text = (parsedChannels + searchText).joinToString(" ").lowercase()
|
||||||
|
val channels = mutableListOf<DebridStreamAudioChannel>()
|
||||||
|
if (text.hasToken("7.1")) channels += DebridStreamAudioChannel.CH_7_1
|
||||||
|
if (text.hasToken("6.1")) channels += DebridStreamAudioChannel.CH_6_1
|
||||||
|
if (text.hasToken("5.1") || text.hasToken("6ch")) channels += DebridStreamAudioChannel.CH_5_1
|
||||||
|
if (text.hasToken("2.0")) channels += DebridStreamAudioChannel.CH_2_0
|
||||||
|
return channels.distinct().ifEmpty { listOf(DebridStreamAudioChannel.UNKNOWN) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamEncode(parsedCodec: String?, searchText: String): DebridStreamEncode {
|
||||||
|
val text = listOfNotNull(parsedCodec, searchText).joinToString(" ").lowercase()
|
||||||
|
return when {
|
||||||
|
text.hasToken("av1") -> DebridStreamEncode.AV1
|
||||||
|
text.hasToken("hevc") || text.hasToken("h265") || text.hasToken("x265") -> DebridStreamEncode.HEVC
|
||||||
|
text.hasToken("avc") || text.hasToken("h264") || text.hasToken("x264") -> DebridStreamEncode.AVC
|
||||||
|
text.hasToken("xvid") -> DebridStreamEncode.XVID
|
||||||
|
text.hasToken("divx") -> DebridStreamEncode.DIVX
|
||||||
|
else -> DebridStreamEncode.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun languageFor(value: String): DebridStreamLanguage? {
|
||||||
|
val normalized = value.lowercase()
|
||||||
|
return DebridStreamLanguage.entries.firstOrNull {
|
||||||
|
normalized == it.code || normalized == it.label.lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseGroupFromText(text: String): String =
|
||||||
|
Regex("-([a-z0-9][a-z0-9._]{1,24})($|\\.)", RegexOption.IGNORE_CASE)
|
||||||
|
.find(text)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
private fun <T> rank(value: T, preferred: List<T>): Int {
|
||||||
|
val index = preferred.indexOf(value)
|
||||||
|
return if (index >= 0) index else Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> rankAny(values: List<T>, preferred: List<T>): Int =
|
||||||
|
values.minOfOrNull { rank(it, preferred) } ?: Int.MAX_VALUE
|
||||||
|
|
||||||
|
private fun String.hasResolutionToken(vararg tokens: String): Boolean =
|
||||||
|
Regex("(^|[^a-z0-9])(${tokens.joinToString("|")})([^a-z0-9]|\$)").containsMatchIn(this)
|
||||||
|
|
||||||
|
private fun String.hasToken(token: String): Boolean =
|
||||||
|
Regex("(^|[^a-z0-9])${Regex.escape(token.lowercase())}([^a-z0-9]|\$)").containsMatchIn(lowercase())
|
||||||
|
|
||||||
|
private fun String.isDolbyVisionToken(): Boolean {
|
||||||
|
val normalized = lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||||
|
return normalized == "dv" || normalized == "dovi" || normalized == "dolbyvision"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isHdrToken(): Boolean {
|
||||||
|
val normalized = lowercase().replace(Regex("[^a-z0-9+]"), "")
|
||||||
|
return normalized == "hdr" ||
|
||||||
|
normalized == "hdr10" ||
|
||||||
|
normalized == "hdr10+" ||
|
||||||
|
normalized == "hdr10plus" ||
|
||||||
|
normalized == "hlg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamSize(stream: StreamItem): Long? =
|
||||||
|
stream.clientResolve?.stream?.raw?.size
|
||||||
|
?: stream.behaviorHints.videoSize
|
||||||
|
?: stream.debridCacheStatus?.cachedSize
|
||||||
|
|
||||||
|
private fun streamSearchText(stream: StreamItem): String {
|
||||||
|
val resolve = stream.clientResolve
|
||||||
|
val raw = resolve?.stream?.raw
|
||||||
|
val parsed = raw?.parsed
|
||||||
|
return listOfNotNull(
|
||||||
|
stream.name,
|
||||||
|
stream.title,
|
||||||
|
stream.description,
|
||||||
|
stream.behaviorHints.filename,
|
||||||
|
stream.debridCacheStatus?.cachedName,
|
||||||
|
resolve?.torrentName,
|
||||||
|
resolve?.filename,
|
||||||
|
raw?.torrentName,
|
||||||
|
raw?.filename,
|
||||||
|
parsed?.resolution,
|
||||||
|
parsed?.quality,
|
||||||
|
parsed?.codec,
|
||||||
|
parsed?.hdr?.joinToString(" "),
|
||||||
|
parsed?.audio?.joinToString(" "),
|
||||||
|
).joinToString(" ").lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DebridStreamFacts(
|
||||||
|
val resolution: DebridStreamResolution,
|
||||||
|
val quality: DebridStreamQuality,
|
||||||
|
val visualTags: List<DebridStreamVisualTag>,
|
||||||
|
val audioTags: List<DebridStreamAudioTag>,
|
||||||
|
val audioChannels: List<DebridStreamAudioChannel>,
|
||||||
|
val encode: DebridStreamEncode,
|
||||||
|
val languages: List<DebridStreamLanguage>,
|
||||||
|
val releaseGroup: String,
|
||||||
|
val size: Long?,
|
||||||
|
val resolutionRank: Int,
|
||||||
|
val qualityRank: Int,
|
||||||
|
val visualRank: Int,
|
||||||
|
val audioRank: Int,
|
||||||
|
val channelRank: Int,
|
||||||
|
val encodeRank: Int,
|
||||||
|
val languageRank: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Int.gigabytes(): Long = this * 1_000_000_000L
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
internal data class DebridTemplateBytes(val value: Long)
|
||||||
|
|
||||||
|
class DebridStreamTemplateEngine {
|
||||||
|
fun render(template: String, values: Map<String, Any?>): String {
|
||||||
|
if (template.isEmpty()) return ""
|
||||||
|
val out = StringBuilder()
|
||||||
|
var index = 0
|
||||||
|
while (index < template.length) {
|
||||||
|
val start = template.indexOf('{', index)
|
||||||
|
if (start < 0) {
|
||||||
|
out.append(template.substring(index))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.append(template.substring(index, start))
|
||||||
|
val end = findPlaceholderEnd(template, start + 1)
|
||||||
|
if (end < 0) {
|
||||||
|
out.append(template.substring(start))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val expression = template.substring(start + 1, end)
|
||||||
|
out.append(renderExpression(expression, values))
|
||||||
|
index = end + 1
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderExpression(expression: String, values: Map<String, Any?>): String {
|
||||||
|
val bracket = findTopLevelChar(expression, '[')
|
||||||
|
if (bracket >= 0 && expression.endsWith("]")) {
|
||||||
|
val condition = expression.substring(0, bracket)
|
||||||
|
val branches = parseBranches(expression.substring(bracket + 1, expression.length - 1))
|
||||||
|
val selected = if (evaluateCondition(condition, values)) branches.first else branches.second
|
||||||
|
return render(selected, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tokens = splitOps(expression)
|
||||||
|
if (tokens.isEmpty()) return ""
|
||||||
|
var value: Any? = values[tokens.first()]
|
||||||
|
tokens.drop(1).forEach { op ->
|
||||||
|
value = applyTransform(value, op)
|
||||||
|
}
|
||||||
|
return valueToText(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evaluateCondition(expression: String, values: Map<String, Any?>): Boolean {
|
||||||
|
val tokens = splitOps(expression).filter { it.isNotBlank() }
|
||||||
|
if (tokens.isEmpty()) return false
|
||||||
|
val groups = mutableListOf<MutableList<Boolean>>()
|
||||||
|
var currentGroup = mutableListOf<Boolean>()
|
||||||
|
var index = 0
|
||||||
|
while (index < tokens.size) {
|
||||||
|
when (tokens[index]) {
|
||||||
|
"or" -> {
|
||||||
|
groups += currentGroup
|
||||||
|
currentGroup = mutableListOf()
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
"and" -> index++
|
||||||
|
else -> {
|
||||||
|
val field = tokens[index]
|
||||||
|
index++
|
||||||
|
val ops = mutableListOf<String>()
|
||||||
|
while (
|
||||||
|
index < tokens.size &&
|
||||||
|
tokens[index] != "and" &&
|
||||||
|
tokens[index] != "or" &&
|
||||||
|
!tokens[index].isFieldPath()
|
||||||
|
) {
|
||||||
|
ops += tokens[index]
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
currentGroup += evaluateSingleCondition(values[field], ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups += currentGroup
|
||||||
|
return groups.any { group -> group.isNotEmpty() && group.all { it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evaluateSingleCondition(value: Any?, ops: List<String>): Boolean {
|
||||||
|
if (ops.isEmpty()) return isTruthy(value)
|
||||||
|
var result = false
|
||||||
|
var hasResult = false
|
||||||
|
ops.forEach { op ->
|
||||||
|
when {
|
||||||
|
op == "exists" -> {
|
||||||
|
result = exists(value)
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op == "istrue" -> {
|
||||||
|
result = if (hasResult) result else asBoolean(value) == true
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op == "isfalse" -> {
|
||||||
|
result = if (hasResult) !result else asBoolean(value) == false
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op.startsWith("~=") -> {
|
||||||
|
result = containsText(value, op.drop(2).trim())
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op.startsWith("~") -> {
|
||||||
|
result = containsText(value, op.drop(1).trim())
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op.startsWith("=") -> {
|
||||||
|
result = equalsText(value, op.drop(1).trim())
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op.startsWith(">=") -> {
|
||||||
|
result = compareNumber(value, op.drop(2)) { left, right -> left >= right }
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op.startsWith("<=") -> {
|
||||||
|
result = compareNumber(value, op.drop(2)) { left, right -> left <= right }
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op.startsWith(">") -> {
|
||||||
|
result = compareNumber(value, op.drop(1)) { left, right -> left > right }
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
op.startsWith("<") -> {
|
||||||
|
result = compareNumber(value, op.drop(1)) { left, right -> left < right }
|
||||||
|
hasResult = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyTransform(value: Any?, op: String): Any? =
|
||||||
|
when {
|
||||||
|
op == "title" -> valueToText(value).titleCased()
|
||||||
|
op == "lower" -> valueToText(value).lowercase()
|
||||||
|
op == "upper" -> valueToText(value).uppercase()
|
||||||
|
op == "bytes" -> asNumber(value)?.let { formatBytes(it) }.orEmpty()
|
||||||
|
op == "time" -> asNumber(value)?.let { formatTime(it) }.orEmpty()
|
||||||
|
op.startsWith("join(") -> {
|
||||||
|
val separator = parseArgs(op).firstOrNull() ?: ", "
|
||||||
|
when (value) {
|
||||||
|
is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }
|
||||||
|
.joinToString(separator)
|
||||||
|
else -> valueToText(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op.startsWith("replace(") -> {
|
||||||
|
val args = parseArgs(op)
|
||||||
|
if (args.size < 2) valueToText(value) else valueToText(value).replace(args[0], args[1])
|
||||||
|
}
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findPlaceholderEnd(text: String, start: Int): Int {
|
||||||
|
var quote: Char? = null
|
||||||
|
var index = start
|
||||||
|
while (index < text.length) {
|
||||||
|
val char = text[index]
|
||||||
|
if (quote != null) {
|
||||||
|
if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null
|
||||||
|
} else {
|
||||||
|
when (char) {
|
||||||
|
'\'', '"' -> quote = char
|
||||||
|
'}' -> return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findTopLevelChar(text: String, target: Char): Int {
|
||||||
|
var quote: Char? = null
|
||||||
|
var parenDepth = 0
|
||||||
|
text.forEachIndexed { index, char ->
|
||||||
|
if (quote != null) {
|
||||||
|
if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
when (char) {
|
||||||
|
'\'', '"' -> quote = char
|
||||||
|
'(' -> parenDepth++
|
||||||
|
')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0)
|
||||||
|
target -> if (parenDepth == 0) return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitOps(text: String): List<String> {
|
||||||
|
val tokens = mutableListOf<String>()
|
||||||
|
var quote: Char? = null
|
||||||
|
var parenDepth = 0
|
||||||
|
var start = 0
|
||||||
|
var index = 0
|
||||||
|
while (index < text.length) {
|
||||||
|
val char = text[index]
|
||||||
|
if (quote != null) {
|
||||||
|
if (char == quote && text.getOrNull(index - 1) != '\\') quote = null
|
||||||
|
index++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (char) {
|
||||||
|
'\'', '"' -> quote = char
|
||||||
|
'(' -> parenDepth++
|
||||||
|
')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0)
|
||||||
|
':' -> {
|
||||||
|
if (parenDepth == 0 && text.getOrNull(index + 1) == ':') {
|
||||||
|
tokens += text.substring(start, index).trim()
|
||||||
|
index += 2
|
||||||
|
start = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
tokens += text.substring(start).trim()
|
||||||
|
return tokens.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseBranches(text: String): Pair<String, String> {
|
||||||
|
val split = findBranchSeparator(text)
|
||||||
|
if (split < 0) return parseQuoted(text) to ""
|
||||||
|
return parseQuoted(text.substring(0, split)) to parseQuoted(text.substring(split + 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findBranchSeparator(text: String): Int {
|
||||||
|
var quote: Char? = null
|
||||||
|
text.forEachIndexed { index, char ->
|
||||||
|
if (quote != null) {
|
||||||
|
if (char == quote && text.getOrNull(index - 1) != '\\') quote = null
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
when (char) {
|
||||||
|
'\'', '"' -> quote = char
|
||||||
|
'|' -> if (text.getOrNull(index + 1) == '|') return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseArgs(op: String): List<String> {
|
||||||
|
val start = op.indexOf('(')
|
||||||
|
val end = op.lastIndexOf(')')
|
||||||
|
if (start < 0 || end <= start) return emptyList()
|
||||||
|
val body = op.substring(start + 1, end)
|
||||||
|
val args = mutableListOf<String>()
|
||||||
|
var quote: Char? = null
|
||||||
|
var argStart = 0
|
||||||
|
body.forEachIndexed { index, char ->
|
||||||
|
if (quote != null) {
|
||||||
|
if (char == quote && body.getOrNull(index - 1) != '\\') quote = null
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
when (char) {
|
||||||
|
'\'', '"' -> quote = char
|
||||||
|
',' -> {
|
||||||
|
args += parseQuoted(body.substring(argStart, index))
|
||||||
|
argStart = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args += parseQuoted(body.substring(argStart))
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseQuoted(raw: String): String {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
val unquoted = if (
|
||||||
|
trimmed.length >= 2 &&
|
||||||
|
((trimmed.first() == '"' && trimmed.last() == '"') ||
|
||||||
|
(trimmed.first() == '\'' && trimmed.last() == '\''))
|
||||||
|
) {
|
||||||
|
trimmed.substring(1, trimmed.length - 1)
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
return unquoted
|
||||||
|
.replace("\\n", "\n")
|
||||||
|
.replace("\\\"", "\"")
|
||||||
|
.replace("\\'", "'")
|
||||||
|
.replace("\\\\", "\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isFieldPath(): Boolean =
|
||||||
|
startsWith("stream.") || startsWith("service.") || startsWith("addon.")
|
||||||
|
|
||||||
|
private fun exists(value: Any?): Boolean =
|
||||||
|
when (value) {
|
||||||
|
null -> false
|
||||||
|
is String -> value.isNotBlank()
|
||||||
|
is Iterable<*> -> value.any()
|
||||||
|
is Array<*> -> value.isNotEmpty()
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTruthy(value: Any?): Boolean =
|
||||||
|
when (value) {
|
||||||
|
is Boolean -> value
|
||||||
|
is DebridTemplateBytes -> value.value != 0L
|
||||||
|
is Number -> value.toDouble() != 0.0
|
||||||
|
else -> exists(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun asBoolean(value: Any?): Boolean? =
|
||||||
|
when (value) {
|
||||||
|
is Boolean -> value
|
||||||
|
is String -> value.toBooleanStrictOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun asNumber(value: Any?): Double? =
|
||||||
|
when (value) {
|
||||||
|
is Number -> value.toDouble()
|
||||||
|
is DebridTemplateBytes -> value.value.toDouble()
|
||||||
|
is String -> value.toDoubleOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareNumber(value: Any?, rawTarget: String, compare: (Double, Double) -> Boolean): Boolean {
|
||||||
|
val left = asNumber(value) ?: return false
|
||||||
|
val right = rawTarget.trim().toDoubleOrNull() ?: return false
|
||||||
|
return compare(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun equalsText(value: Any?, target: String): Boolean =
|
||||||
|
when (value) {
|
||||||
|
is Iterable<*> -> value.any { valueToText(it).trim().equals(target, ignoreCase = true) }
|
||||||
|
else -> valueToText(value).trim().equals(target, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun containsText(value: Any?, target: String): Boolean =
|
||||||
|
when (value) {
|
||||||
|
is Iterable<*> -> value.any { valueToText(it).contains(target, ignoreCase = true) }
|
||||||
|
else -> valueToText(value).contains(target, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun valueToText(value: Any?): String =
|
||||||
|
when (value) {
|
||||||
|
null -> ""
|
||||||
|
is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }
|
||||||
|
.joinToString(", ")
|
||||||
|
is DebridTemplateBytes -> formatBytes(value.value.toDouble())
|
||||||
|
is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString()
|
||||||
|
is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString()
|
||||||
|
else -> value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.titleCased(): String =
|
||||||
|
split(Regex("\\s+"))
|
||||||
|
.joinToString(" ") { word ->
|
||||||
|
if (word.isBlank()) {
|
||||||
|
word
|
||||||
|
} else {
|
||||||
|
word.lowercase().replaceFirstChar { char ->
|
||||||
|
if (char.isLowerCase()) char.titlecase() else char.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBytes(value: Double): String {
|
||||||
|
val bytes = abs(value)
|
||||||
|
if (bytes < 1024.0) return "${value.toLong()} B"
|
||||||
|
val units = listOf("KB", "MB", "GB", "TB")
|
||||||
|
var current = bytes
|
||||||
|
var unitIndex = -1
|
||||||
|
while (current >= 1024.0 && unitIndex < units.lastIndex) {
|
||||||
|
current /= 1024.0
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
val signed = if (value < 0) -current else current
|
||||||
|
return if (signed >= 10 || signed % 1.0 == 0.0) {
|
||||||
|
"${signed.toLong()} ${units[unitIndex]}"
|
||||||
|
} else {
|
||||||
|
val tenths = (signed * 10.0).roundToLong()
|
||||||
|
"${tenths / 10}.${abs(tenths % 10)} ${units[unitIndex]}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(value: Double): String {
|
||||||
|
val seconds = value.toLong()
|
||||||
|
val hours = seconds / 3600
|
||||||
|
val minutes = (seconds % 3600) / 60
|
||||||
|
val remainingSeconds = seconds % 60
|
||||||
|
return when {
|
||||||
|
hours > 0 -> "${hours}h ${minutes}m"
|
||||||
|
minutes > 0 -> "${minutes}m ${remainingSeconds}s"
|
||||||
|
else -> "${remainingSeconds}s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
internal fun encodePathSegment(value: String): String =
|
||||||
|
percentEncode(value, spaceAsPlus = false)
|
||||||
|
|
||||||
|
internal fun encodeFormValue(value: String): String =
|
||||||
|
percentEncode(value, spaceAsPlus = true)
|
||||||
|
|
||||||
|
internal fun queryString(vararg pairs: Pair<String, String?>): String =
|
||||||
|
pairs
|
||||||
|
.mapNotNull { (key, value) ->
|
||||||
|
value?.let { "${encodePathSegment(key)}=${encodePathSegment(it)}" }
|
||||||
|
}
|
||||||
|
.joinToString("&")
|
||||||
|
|
||||||
|
private fun percentEncode(value: String, spaceAsPlus: Boolean): String = buildString {
|
||||||
|
val hex = "0123456789ABCDEF"
|
||||||
|
value.encodeToByteArray().forEach { byte ->
|
||||||
|
val code = byte.toInt() and 0xFF
|
||||||
|
val isUnreserved = (code in 'A'.code..'Z'.code) ||
|
||||||
|
(code in 'a'.code..'z'.code) ||
|
||||||
|
(code in '0'.code..'9'.code) ||
|
||||||
|
code == '-'.code ||
|
||||||
|
code == '.'.code ||
|
||||||
|
code == '_'.code ||
|
||||||
|
code == '~'.code
|
||||||
|
when {
|
||||||
|
isUnreserved -> append(code.toChar())
|
||||||
|
spaceAsPlus && code == 0x20 -> append('+')
|
||||||
|
else -> {
|
||||||
|
append('%')
|
||||||
|
append(hex[code shr 4])
|
||||||
|
append(hex[code and 0x0F])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.StreamBehaviorHints
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolve
|
||||||
|
import com.nuvio.app.features.streams.StreamDebridCacheState
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
import com.nuvio.app.features.streams.epochMs
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.debrid_missing_api_key
|
||||||
|
import nuvio.composeapp.generated.resources.debrid_not_cached
|
||||||
|
import nuvio.composeapp.generated.resources.debrid_resolve_failed
|
||||||
|
import nuvio.composeapp.generated.resources.debrid_stream_stale
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
|
object DirectDebridPlaybackResolver {
|
||||||
|
private val localAddonStreamResolver = LocalDebridAddonStreamResolver()
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val resolvedCache = mutableMapOf<String, CachedDirectDebridResolve>()
|
||||||
|
private val inFlightResolves = mutableMapOf<String, Deferred<DirectDebridResolveResult>>()
|
||||||
|
|
||||||
|
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
|
||||||
|
if (!shouldResolveToPlayableStream(stream)) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val cacheKey = stream.debridResolveCacheKey(season, episode)
|
||||||
|
if (cacheKey == null) {
|
||||||
|
return resolveUncached(stream, season, episode)
|
||||||
|
}
|
||||||
|
getCachedResult(cacheKey)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownsResolve = false
|
||||||
|
val newResolve = scope.async(start = CoroutineStart.LAZY) {
|
||||||
|
resolveUncached(stream, season, episode)
|
||||||
|
}
|
||||||
|
val activeResolve = mutex.withLock {
|
||||||
|
getCachedResultLocked(cacheKey)?.let { cached ->
|
||||||
|
return@withLock null to cached
|
||||||
|
}
|
||||||
|
val existing = inFlightResolves[cacheKey]
|
||||||
|
if (existing != null) {
|
||||||
|
existing to null
|
||||||
|
} else {
|
||||||
|
inFlightResolves[cacheKey] = newResolve
|
||||||
|
ownsResolve = true
|
||||||
|
newResolve to null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeResolve.second?.let {
|
||||||
|
newResolve.cancel()
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val deferred = activeResolve.first ?: return DirectDebridResolveResult.Error
|
||||||
|
if (!ownsResolve) newResolve.cancel()
|
||||||
|
if (ownsResolve) deferred.start()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val result = deferred.await()
|
||||||
|
if (ownsResolve && result is DirectDebridResolveResult.Success) {
|
||||||
|
mutex.withLock {
|
||||||
|
resolvedCache[cacheKey] = CachedDirectDebridResolve(
|
||||||
|
result = result,
|
||||||
|
cachedAtMs = epochMs(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} finally {
|
||||||
|
if (ownsResolve) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (inFlightResolves[cacheKey] === deferred) {
|
||||||
|
inFlightResolves.remove(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cachedPlayableStream(stream: StreamItem, season: Int?, episode: Int?): StreamItem? {
|
||||||
|
if (!shouldResolveToPlayableStream(stream)) return null
|
||||||
|
val cacheKey = stream.debridResolveCacheKey(season, episode) ?: return null
|
||||||
|
return getCachedResult(cacheKey)
|
||||||
|
?.let { result -> stream.withResolvedDebridUrl(result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCachedResult(cacheKey: String): DirectDebridResolveResult.Success? =
|
||||||
|
mutex.withLock { getCachedResultLocked(cacheKey) }
|
||||||
|
|
||||||
|
private fun getCachedResultLocked(cacheKey: String): DirectDebridResolveResult.Success? {
|
||||||
|
val cached = resolvedCache[cacheKey] ?: return null
|
||||||
|
val age = epochMs() - cached.cachedAtMs
|
||||||
|
return if (age in 0..DEBRID_RESOLVE_CACHE_TTL_MS) {
|
||||||
|
cached.result
|
||||||
|
} else {
|
||||||
|
resolvedCache.remove(cacheKey)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldResolveToPlayableStream(stream: StreamItem): Boolean {
|
||||||
|
val settings = DebridSettingsRepository.snapshot()
|
||||||
|
if (!settings.canResolvePlayableLinks) return false
|
||||||
|
if (stream.needsLocalDebridResolve) {
|
||||||
|
return stream.isInstalledAddonStream && localTorrentResolveCredential(settings) != null
|
||||||
|
}
|
||||||
|
if (!stream.isInstalledAddonStream || !stream.isDirectDebridStream || stream.playableDirectUrl != null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id ?: return false
|
||||||
|
return providerId == settings.activeResolverProviderId &&
|
||||||
|
settings.apiKeyFor(providerId).isNotBlank() &&
|
||||||
|
DebridProviderApis.apiFor(providerId) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
|
||||||
|
if (stream.needsLocalDebridResolve) {
|
||||||
|
return localAddonStreamResolver.resolve(stream, season, episode)
|
||||||
|
}
|
||||||
|
val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id
|
||||||
|
?: return DirectDebridResolveResult.Error
|
||||||
|
val settings = DebridSettingsRepository.snapshot()
|
||||||
|
if (providerId != settings.activeResolverProviderId) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val apiKey = settings
|
||||||
|
.apiKeyFor(providerId)
|
||||||
|
.trim()
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?: return DirectDebridResolveResult.MissingApiKey
|
||||||
|
val api = DebridProviderApis.apiFor(providerId) ?: return DirectDebridResolveResult.Error
|
||||||
|
return api.resolveClientStream(stream, apiKey, season, episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolveToPlayableStream(
|
||||||
|
stream: StreamItem,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): DirectDebridPlayableResult {
|
||||||
|
if (!shouldResolveToPlayableStream(stream)) {
|
||||||
|
return DirectDebridPlayableResult.Success(stream)
|
||||||
|
}
|
||||||
|
return when (val result = resolve(stream, season, episode)) {
|
||||||
|
is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result))
|
||||||
|
DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey
|
||||||
|
DirectDebridResolveResult.NotCached -> DirectDebridPlayableResult.NotCached
|
||||||
|
DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale
|
||||||
|
DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L
|
||||||
|
|
||||||
|
private data class CachedDirectDebridResolve(
|
||||||
|
val result: DirectDebridResolveResult.Success,
|
||||||
|
val cachedAtMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class DirectDebridPlayableResult {
|
||||||
|
data class Success(val stream: StreamItem) : DirectDebridPlayableResult()
|
||||||
|
data object MissingApiKey : DirectDebridPlayableResult()
|
||||||
|
data object NotCached : DirectDebridPlayableResult()
|
||||||
|
data object Stale : DirectDebridPlayableResult()
|
||||||
|
data object Error : DirectDebridPlayableResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class DirectDebridResolveResult {
|
||||||
|
data class Success(
|
||||||
|
val url: String,
|
||||||
|
val filename: String?,
|
||||||
|
val videoSize: Long?,
|
||||||
|
) : DirectDebridResolveResult()
|
||||||
|
|
||||||
|
data object MissingApiKey : DirectDebridResolveResult()
|
||||||
|
data object NotCached : DirectDebridResolveResult()
|
||||||
|
data object Stale : DirectDebridResolveResult()
|
||||||
|
data object Error : DirectDebridResolveResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun DirectDebridPlayableResult.toastMessage(): String? =
|
||||||
|
when (this) {
|
||||||
|
is DirectDebridPlayableResult.Success -> null
|
||||||
|
DirectDebridPlayableResult.MissingApiKey -> runBlocking { getString(Res.string.debrid_missing_api_key) }
|
||||||
|
DirectDebridPlayableResult.NotCached -> runBlocking { getString(Res.string.debrid_not_cached) }
|
||||||
|
DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) }
|
||||||
|
DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LocalDebridAddonStreamResolver(
|
||||||
|
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
|
||||||
|
private val premiumizeFileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(),
|
||||||
|
) {
|
||||||
|
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
|
||||||
|
val account = localTorrentResolveCredential() ?: return DirectDebridResolveResult.MissingApiKey
|
||||||
|
val apiKey = account.apiKey.trim()
|
||||||
|
|
||||||
|
val hash = stream.infoHash?.trim()?.lowercase()
|
||||||
|
if (stream.debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED) {
|
||||||
|
return DirectDebridResolveResult.NotCached
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!hash.isNullOrBlank() &&
|
||||||
|
stream.debridCacheStatus?.state != StreamDebridCacheState.CACHED &&
|
||||||
|
account.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck)
|
||||||
|
) {
|
||||||
|
when (LocalDebridService.isCached(account, hash)) {
|
||||||
|
false -> return DirectDebridResolveResult.NotCached
|
||||||
|
true, null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val magnet = DebridMagnetBuilder.fromStream(stream)
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
val resolve = stream.toResolveMetadata(season, episode, account.provider.id)
|
||||||
|
|
||||||
|
return when (account.provider.id) {
|
||||||
|
DebridProviders.TORBOX_ID -> resolveTorbox(stream, resolve, apiKey, magnet, season, episode)
|
||||||
|
DebridProviders.PREMIUMIZE_ID -> resolvePremiumizeDirectDownload(
|
||||||
|
apiKey = apiKey,
|
||||||
|
source = magnet,
|
||||||
|
resolve = resolve,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
fallbackFilename = stream.behaviorHints.filename,
|
||||||
|
fallbackSize = stream.behaviorHints.videoSize,
|
||||||
|
fileSelector = premiumizeFileSelector,
|
||||||
|
)
|
||||||
|
else -> DirectDebridResolveResult.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveTorbox(
|
||||||
|
stream: StreamItem,
|
||||||
|
resolve: StreamClientResolve,
|
||||||
|
apiKey: String,
|
||||||
|
magnet: String,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
): DirectDebridResolveResult {
|
||||||
|
return try {
|
||||||
|
val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet)
|
||||||
|
val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId()
|
||||||
|
?: return create.toFailureForCreate()
|
||||||
|
|
||||||
|
val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId)
|
||||||
|
if (!torrent.isSuccessful) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val files = torrent.body?.data?.files.orEmpty()
|
||||||
|
val file = fileSelector.selectFile(files, resolve, season, episode)
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
val fileId = file.id ?: return DirectDebridResolveResult.Stale
|
||||||
|
|
||||||
|
val link = TorboxApiClient.requestDownloadLink(
|
||||||
|
apiKey = apiKey,
|
||||||
|
torrentId = torrentId,
|
||||||
|
fileId = fileId,
|
||||||
|
)
|
||||||
|
if (!link.isSuccessful) {
|
||||||
|
return DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
val url = link.body?.data?.takeIf { it.isNotBlank() }
|
||||||
|
?: return DirectDebridResolveResult.Stale
|
||||||
|
|
||||||
|
DirectDebridResolveResult.Success(
|
||||||
|
url = url,
|
||||||
|
filename = file.displayName().takeIf { it.isNotBlank() }
|
||||||
|
?: stream.behaviorHints.filename?.takeIf { it.isNotBlank() },
|
||||||
|
videoSize = file.size ?: stream.behaviorHints.videoSize,
|
||||||
|
)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
DirectDebridResolveResult.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localTorrentResolveCredential(
|
||||||
|
settings: DebridSettings = DebridSettingsRepository.snapshot(),
|
||||||
|
): DebridServiceCredential? =
|
||||||
|
settings.activeResolverCredential
|
||||||
|
?.takeIf { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) }
|
||||||
|
|
||||||
|
private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): String? {
|
||||||
|
val resolve = clientResolve
|
||||||
|
if (resolve == null && needsLocalDebridResolve) {
|
||||||
|
val account = localTorrentResolveCredential() ?: return null
|
||||||
|
val apiKey = account.apiKey.trim().takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val identity = infoHash ?: torrentMagnetUri ?: behaviorHints.filename ?: return null
|
||||||
|
return listOf(
|
||||||
|
account.provider.id,
|
||||||
|
apiKey.stableFingerprint(),
|
||||||
|
identity.trim().lowercase(),
|
||||||
|
fileIdx?.toString().orEmpty(),
|
||||||
|
behaviorHints.filename.orEmpty().trim().lowercase(),
|
||||||
|
season?.toString().orEmpty(),
|
||||||
|
episode?.toString().orEmpty(),
|
||||||
|
).joinToString("|")
|
||||||
|
}
|
||||||
|
resolve ?: return null
|
||||||
|
val providerId = DebridProviders.byId(resolve.service)?.id ?: return null
|
||||||
|
val settings = DebridSettingsRepository.snapshot()
|
||||||
|
if (providerId != settings.activeResolverProviderId) return null
|
||||||
|
val apiKey = settings
|
||||||
|
.apiKeyFor(providerId)
|
||||||
|
.trim()
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?: return null
|
||||||
|
val identity = resolve.infoHash
|
||||||
|
?: resolve.magnetUri
|
||||||
|
?: resolve.torrentName
|
||||||
|
?: resolve.filename
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
providerId,
|
||||||
|
apiKey.stableFingerprint(),
|
||||||
|
identity.trim().lowercase(),
|
||||||
|
resolve.fileIdx?.toString().orEmpty(),
|
||||||
|
(resolve.filename ?: behaviorHints.filename).orEmpty().trim().lowercase(),
|
||||||
|
(season ?: resolve.season)?.toString().orEmpty(),
|
||||||
|
(episode ?: resolve.episode)?.toString().orEmpty(),
|
||||||
|
).joinToString("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?, providerId: String): StreamClientResolve =
|
||||||
|
StreamClientResolve(
|
||||||
|
type = "torrent",
|
||||||
|
infoHash = infoHash,
|
||||||
|
fileIdx = fileIdx,
|
||||||
|
magnetUri = torrentMagnetUri,
|
||||||
|
sources = sources,
|
||||||
|
torrentName = title ?: name,
|
||||||
|
filename = behaviorHints.filename,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
service = providerId,
|
||||||
|
isCached = debridCacheStatus?.state == StreamDebridCacheState.CACHED,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun DebridApiResponse<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.toFailureForCreate(): DirectDebridResolveResult =
|
||||||
|
when (status) {
|
||||||
|
401, 403 -> DirectDebridResolveResult.Error
|
||||||
|
409 -> DirectDebridResolveResult.NotCached
|
||||||
|
else -> DirectDebridResolveResult.Stale
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.stableFingerprint(): String {
|
||||||
|
val hash = fold(1125899906842597L) { acc, char -> (acc * 31L) + char.code }
|
||||||
|
return hash.toULong().toString(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StreamItem.withResolvedDebridUrl(result: DirectDebridResolveResult.Success): StreamItem =
|
||||||
|
copy(
|
||||||
|
url = result.url,
|
||||||
|
externalUrl = null,
|
||||||
|
behaviorHints = behaviorHints.mergeResolvedDebridHints(result),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun StreamBehaviorHints.mergeResolvedDebridHints(result: DirectDebridResolveResult.Success): StreamBehaviorHints =
|
||||||
|
copy(
|
||||||
|
filename = result.filename ?: filename,
|
||||||
|
videoSize = result.videoSize ?: videoSize,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.features.player.PlayerSettingsUiState
|
||||||
|
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||||
|
import com.nuvio.app.features.streams.StreamAutoPlayMode
|
||||||
|
import com.nuvio.app.features.streams.StreamAutoPlaySelector
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
import com.nuvio.app.features.streams.epochMs
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
object DirectDebridStreamPreparer {
|
||||||
|
private val log = Logger.withTag("DirectDebridPreparer")
|
||||||
|
private val budgetMutex = Mutex()
|
||||||
|
private val minuteStarts = ArrayDeque<Long>()
|
||||||
|
private val hourStarts = ArrayDeque<Long>()
|
||||||
|
|
||||||
|
suspend fun prepare(
|
||||||
|
streams: List<StreamItem>,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
playerSettings: PlayerSettingsUiState,
|
||||||
|
installedAddonNames: Set<String>,
|
||||||
|
onPrepared: (original: StreamItem, prepared: StreamItem) -> Unit,
|
||||||
|
) {
|
||||||
|
val settings = DebridSettingsRepository.snapshot()
|
||||||
|
val limit = settings.instantPlaybackPreparationLimit
|
||||||
|
if (!settings.canResolvePlayableLinks || limit <= 0) return
|
||||||
|
|
||||||
|
val candidates = prioritizeCandidates(
|
||||||
|
streams = streams.filter(DirectDebridPlaybackResolver::shouldResolveToPlayableStream),
|
||||||
|
limit = limit,
|
||||||
|
playerSettings = playerSettings,
|
||||||
|
installedAddonNames = installedAddonNames,
|
||||||
|
)
|
||||||
|
for (stream in candidates) {
|
||||||
|
DirectDebridPlaybackResolver.cachedPlayableStream(stream, season, episode)?.let { cached ->
|
||||||
|
onPrepared(stream, cached)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!consumeBackgroundBudget()) {
|
||||||
|
log.d { "Skipping instant playback preparation; local debrid budget reached" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) {
|
||||||
|
is DirectDebridPlayableResult.Success -> {
|
||||||
|
if (result.stream.playableDirectUrl != null) {
|
||||||
|
onPrepared(stream, result.stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
log.d(error) { "Instant playback preparation failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun prioritizeCandidates(
|
||||||
|
streams: List<StreamItem>,
|
||||||
|
limit: Int,
|
||||||
|
playerSettings: PlayerSettingsUiState,
|
||||||
|
installedAddonNames: Set<String>,
|
||||||
|
): List<StreamItem> {
|
||||||
|
if (limit <= 0) return emptyList()
|
||||||
|
val candidates = streams
|
||||||
|
.filter { stream ->
|
||||||
|
stream.playableDirectUrl == null &&
|
||||||
|
stream.isAddonDebridCandidate &&
|
||||||
|
(stream.isDirectDebridStream || stream.isCachedDebridTorrentStream)
|
||||||
|
}
|
||||||
|
.distinctBy { it.preparationKey() }
|
||||||
|
if (candidates.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val prioritized = mutableListOf<StreamItem>()
|
||||||
|
val autoPlaySelection = StreamAutoPlaySelector.selectAutoPlayStream(
|
||||||
|
streams = streams,
|
||||||
|
mode = playerSettings.streamAutoPlayMode,
|
||||||
|
regexPattern = playerSettings.streamAutoPlayRegex,
|
||||||
|
source = playerSettings.streamAutoPlaySource,
|
||||||
|
installedAddonNames = installedAddonNames,
|
||||||
|
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
|
||||||
|
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
|
||||||
|
)
|
||||||
|
if (autoPlaySelection?.let { it.isAddonDebridCandidate && (it.isDirectDebridStream || it.isCachedDebridTorrentStream) } == true) {
|
||||||
|
candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() }
|
||||||
|
?.let(prioritized::add)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) {
|
||||||
|
val regex = runCatching {
|
||||||
|
Regex(playerSettings.streamAutoPlayRegex.trim(), RegexOption.IGNORE_CASE)
|
||||||
|
}.getOrNull()
|
||||||
|
if (regex != null) {
|
||||||
|
candidates
|
||||||
|
.filter { candidate ->
|
||||||
|
prioritized.none { it.preparationKey() == candidate.preparationKey() } &&
|
||||||
|
regex.containsMatchIn(candidate.searchableText())
|
||||||
|
}
|
||||||
|
.forEach(prioritized::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.filter { candidate -> prioritized.none { it.preparationKey() == candidate.preparationKey() } }
|
||||||
|
.forEach(prioritized::add)
|
||||||
|
|
||||||
|
return prioritized.take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replacePreparedStream(
|
||||||
|
groups: List<AddonStreamGroup>,
|
||||||
|
original: StreamItem,
|
||||||
|
prepared: StreamItem,
|
||||||
|
eligibleGroupIds: Set<String>? = null,
|
||||||
|
): List<AddonStreamGroup> {
|
||||||
|
val key = original.preparationKey()
|
||||||
|
return groups.map { group ->
|
||||||
|
if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group
|
||||||
|
var changed = false
|
||||||
|
val updatedStreams = group.streams.map { stream ->
|
||||||
|
if (stream.preparationKey() == key) {
|
||||||
|
changed = true
|
||||||
|
prepared.copy(
|
||||||
|
addonName = stream.addonName,
|
||||||
|
addonId = stream.addonId,
|
||||||
|
sourceName = stream.sourceName,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) group.copy(streams = updatedStreams) else group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun consumeBackgroundBudget(): Boolean {
|
||||||
|
val now = epochMs()
|
||||||
|
return budgetMutex.withLock {
|
||||||
|
minuteStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS)
|
||||||
|
hourStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS)
|
||||||
|
if (
|
||||||
|
minuteStarts.size >= MAX_BACKGROUND_PREPARES_PER_MINUTE ||
|
||||||
|
hourStarts.size >= MAX_BACKGROUND_PREPARES_PER_HOUR
|
||||||
|
) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
minuteStarts.addLast(now)
|
||||||
|
hourStarts.addLast(now)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_BACKGROUND_PREPARES_PER_MINUTE = 6
|
||||||
|
private const val MAX_BACKGROUND_PREPARES_PER_HOUR = 30
|
||||||
|
private const val BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS = 60L * 1000L
|
||||||
|
private const val BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS = 60L * 60L * 1000L
|
||||||
|
|
||||||
|
private fun ArrayDeque<Long>.removeOlderThan(cutoffMs: Long) {
|
||||||
|
while (firstOrNull()?.let { it < cutoffMs } == true) {
|
||||||
|
removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StreamItem.preparationKey(): String {
|
||||||
|
val resolve = clientResolve
|
||||||
|
if (resolve != null) {
|
||||||
|
return listOf(
|
||||||
|
resolve.service.orEmpty().lowercase(),
|
||||||
|
resolve.infoHash.orEmpty().lowercase(),
|
||||||
|
resolve.fileIdx?.toString().orEmpty(),
|
||||||
|
resolve.filename.orEmpty().lowercase(),
|
||||||
|
resolve.torrentName.orEmpty().lowercase(),
|
||||||
|
resolve.magnetUri.orEmpty().lowercase(),
|
||||||
|
).joinToString("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
addonId.lowercase(),
|
||||||
|
infoHash.orEmpty().lowercase(),
|
||||||
|
fileIdx?.toString().orEmpty(),
|
||||||
|
behaviorHints.filename.orEmpty().lowercase(),
|
||||||
|
playableDirectUrl.orEmpty().lowercase(),
|
||||||
|
name.orEmpty().lowercase(),
|
||||||
|
title.orEmpty().lowercase(),
|
||||||
|
).joinToString("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StreamItem.searchableText(): String =
|
||||||
|
buildString {
|
||||||
|
append(addonName).append(' ')
|
||||||
|
append(name.orEmpty()).append(' ')
|
||||||
|
append(title.orEmpty()).append(' ')
|
||||||
|
append(description.orEmpty()).append(' ')
|
||||||
|
append(playableDirectUrl.orEmpty())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||||
|
import com.nuvio.app.features.streams.StreamDebridCacheState
|
||||||
|
import com.nuvio.app.features.streams.StreamDebridCacheStatus
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
|
||||||
|
object LocalDebridAvailabilityService {
|
||||||
|
fun markChecking(
|
||||||
|
groups: List<AddonStreamGroup>,
|
||||||
|
eligibleGroupIds: Set<String>? = null,
|
||||||
|
): List<AddonStreamGroup> {
|
||||||
|
val account = cacheCheckAccount() ?: return groups
|
||||||
|
return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
|
||||||
|
if (stream.localAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) {
|
||||||
|
stream
|
||||||
|
} else {
|
||||||
|
stream.copy(
|
||||||
|
debridCacheStatus = StreamDebridCacheStatus(
|
||||||
|
providerId = account.provider.id,
|
||||||
|
providerName = account.provider.displayName,
|
||||||
|
state = StreamDebridCacheState.CHECKING,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun annotateCachedAvailability(
|
||||||
|
groups: List<AddonStreamGroup>,
|
||||||
|
eligibleGroupIds: Set<String>? = null,
|
||||||
|
): List<AddonStreamGroup> {
|
||||||
|
val account = cacheCheckAccount() ?: return groups
|
||||||
|
val hashes = groups
|
||||||
|
.filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds }
|
||||||
|
.flatMap { group ->
|
||||||
|
group.streams.mapNotNull { stream ->
|
||||||
|
stream.localAvailabilityHash()
|
||||||
|
?.takeUnless { stream.debridCacheStatus?.state in FINAL_CACHE_STATES }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
if (hashes.isEmpty()) return groups
|
||||||
|
|
||||||
|
val cached = LocalDebridService.checkCached(account = account, hashes = hashes)
|
||||||
|
?: return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
|
||||||
|
val hash = stream.localAvailabilityHash()
|
||||||
|
if (hash == null) {
|
||||||
|
stream
|
||||||
|
} else {
|
||||||
|
stream.copy(
|
||||||
|
debridCacheStatus = StreamDebridCacheStatus(
|
||||||
|
providerId = account.provider.id,
|
||||||
|
providerName = account.provider.displayName,
|
||||||
|
state = StreamDebridCacheState.UNKNOWN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
|
||||||
|
val hash = stream.localAvailabilityHash() ?: return@updateAvailabilityStatus stream
|
||||||
|
if (stream.debridCacheStatus?.state in FINAL_CACHE_STATES) return@updateAvailabilityStatus stream
|
||||||
|
val cachedItem = cached[hash]
|
||||||
|
stream.copy(
|
||||||
|
debridCacheStatus = StreamDebridCacheStatus(
|
||||||
|
providerId = account.provider.id,
|
||||||
|
providerName = account.provider.displayName,
|
||||||
|
state = if (cachedItem == null) StreamDebridCacheState.NOT_CACHED else StreamDebridCacheState.CACHED,
|
||||||
|
cachedName = cachedItem?.name,
|
||||||
|
cachedSize = cachedItem?.size,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isCached(hash: String): Boolean? {
|
||||||
|
val account = cacheCheckAccount() ?: return null
|
||||||
|
return LocalDebridService.isCached(account, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheCheckAccount(): DebridServiceCredential? {
|
||||||
|
val settings = DebridSettingsRepository.snapshot()
|
||||||
|
if (!settings.canResolvePlayableLinks) return null
|
||||||
|
return settings.activeResolverCredential
|
||||||
|
?.takeIf { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FINAL_CACHE_STATES = setOf(
|
||||||
|
StreamDebridCacheState.CACHED,
|
||||||
|
StreamDebridCacheState.NOT_CACHED,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun StreamItem.localAvailabilityHash(): String? =
|
||||||
|
infoHash
|
||||||
|
?.trim()
|
||||||
|
?.lowercase()
|
||||||
|
?.takeIf { isInstalledAddonStream && needsLocalDebridResolve && it.isNotBlank() }
|
||||||
|
|
||||||
|
private fun List<AddonStreamGroup>.updateAvailabilityStatus(
|
||||||
|
eligibleGroupIds: Set<String>?,
|
||||||
|
transform: (StreamItem) -> StreamItem,
|
||||||
|
): List<AddonStreamGroup> =
|
||||||
|
map { group ->
|
||||||
|
if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group
|
||||||
|
var changed = false
|
||||||
|
val updatedStreams = group.streams.map { stream ->
|
||||||
|
val updated = transform(stream)
|
||||||
|
if (updated != stream) changed = true
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
if (changed) group.copy(streams = updatedStreams) else group
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
|
internal data class LocalDebridCachedItem(
|
||||||
|
val name: String?,
|
||||||
|
val size: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object LocalDebridService {
|
||||||
|
suspend fun checkCached(
|
||||||
|
account: DebridServiceCredential,
|
||||||
|
hashes: List<String>,
|
||||||
|
): Map<String, LocalDebridCachedItem>? =
|
||||||
|
when (account.provider.id) {
|
||||||
|
DebridProviders.TORBOX_ID -> checkTorboxCached(account.apiKey, hashes)
|
||||||
|
DebridProviders.PREMIUMIZE_ID -> checkPremiumizeCached(account.apiKey, hashes)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isCached(account: DebridServiceCredential, hash: String): Boolean? {
|
||||||
|
val normalizedHash = hash.trim().lowercase().takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return checkCached(account, listOf(normalizedHash))?.containsKey(normalizedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkTorboxCached(
|
||||||
|
apiKey: String,
|
||||||
|
hashes: List<String>,
|
||||||
|
): Map<String, LocalDebridCachedItem>? =
|
||||||
|
try {
|
||||||
|
val response = TorboxApiClient.checkCached(apiKey = apiKey, hashes = hashes)
|
||||||
|
if (!response.isSuccessful || response.body?.success == false) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
response.body?.data.orEmpty().mapKeys { it.key.lowercase() }.mapValues { (_, value) ->
|
||||||
|
LocalDebridCachedItem(
|
||||||
|
name = value.name,
|
||||||
|
size = value.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkPremiumizeCached(
|
||||||
|
apiKey: String,
|
||||||
|
hashes: List<String>,
|
||||||
|
): Map<String, LocalDebridCachedItem>? =
|
||||||
|
try {
|
||||||
|
val normalizedHashes = hashes
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
if (normalizedHashes.isEmpty()) return emptyMap()
|
||||||
|
val sources = normalizedHashes.map { hash -> "magnet:?xt=urn:btih:$hash" }
|
||||||
|
val response = PremiumizeApiClient.checkCache(apiKey = apiKey, items = sources)
|
||||||
|
val body = response.body
|
||||||
|
if (!response.isSuccessful || body?.status.equals("error", ignoreCase = true)) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
normalizedHashes.mapIndexedNotNull { index, hash ->
|
||||||
|
if (body?.response?.getOrNull(index) != true) return@mapIndexedNotNull null
|
||||||
|
hash to LocalDebridCachedItem(
|
||||||
|
name = body.filename?.getOrNull(index),
|
||||||
|
size = body.filesize?.getOrNull(index)?.asLongOrNull(),
|
||||||
|
)
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun kotlinx.serialization.json.JsonElement?.asLongOrNull(): Long? {
|
||||||
|
val primitive = this as? JsonPrimitive ?: return null
|
||||||
|
return primitive.longOrNull ?: primitive.content.toLongOrNull()
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,7 @@ import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.library.LibraryRepository
|
import com.nuvio.app.features.library.LibraryRepository
|
||||||
import com.nuvio.app.features.library.toLibraryItem
|
import com.nuvio.app.features.library.toLibraryItem
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
|
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
|
||||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
||||||
import com.nuvio.app.features.tmdb.TmdbService
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
|
|
@ -378,6 +379,29 @@ fun MetaDetailsScreen(
|
||||||
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
|
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
|
||||||
}
|
}
|
||||||
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
|
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
|
||||||
|
val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) {
|
||||||
|
if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) {
|
||||||
|
DetailDebridWarmupTarget(
|
||||||
|
videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id,
|
||||||
|
season = seriesAction?.seasonNumber,
|
||||||
|
episode = seriesAction?.episodeNumber,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DetailDebridWarmupTarget(
|
||||||
|
videoId = meta.id,
|
||||||
|
season = null,
|
||||||
|
episode = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(meta.type, debridWarmupTarget) {
|
||||||
|
AddonStreamWarmupRepository.preload(
|
||||||
|
type = meta.type,
|
||||||
|
videoId = debridWarmupTarget.videoId,
|
||||||
|
season = debridWarmupTarget.season,
|
||||||
|
episode = debridWarmupTarget.episode,
|
||||||
|
)
|
||||||
|
}
|
||||||
val hasProductionSection = remember(meta) {
|
val hasProductionSection = remember(meta) {
|
||||||
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
|
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
@ -1367,3 +1391,14 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
|
||||||
} else {
|
} else {
|
||||||
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
|
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class DetailDebridWarmupTarget(
|
||||||
|
val videoId: String,
|
||||||
|
val season: Int?,
|
||||||
|
val episode: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MetaDetails.isSeriesLikeForDebridWarmup(hasEpisodes: Boolean): Boolean =
|
||||||
|
hasEpisodes || type.equals("series", ignoreCase = true) ||
|
||||||
|
type.equals("show", ignoreCase = true) ||
|
||||||
|
type.equals("tv", ignoreCase = true)
|
||||||
|
|
|
||||||
|
|
@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction(
|
||||||
watchedItems: List<WatchedItem>,
|
watchedItems: List<WatchedItem>,
|
||||||
todayIsoDate: String,
|
todayIsoDate: String,
|
||||||
preferFurthestEpisode: Boolean = true,
|
preferFurthestEpisode: Boolean = true,
|
||||||
|
showUnairedNextUp: Boolean = false,
|
||||||
|
): SeriesPrimaryAction? =
|
||||||
|
seriesPrimaryAction(
|
||||||
|
content = WatchingContentRef(type = type, id = id),
|
||||||
|
entries = entries,
|
||||||
|
watchedItems = watchedItems,
|
||||||
|
todayIsoDate = todayIsoDate,
|
||||||
|
preferFurthestEpisode = preferFurthestEpisode,
|
||||||
|
showUnairedNextUp = showUnairedNextUp,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun MetaDetails.seriesPrimaryAction(
|
||||||
|
content: WatchingContentRef,
|
||||||
|
entries: List<WatchProgressEntry>,
|
||||||
|
watchedItems: List<WatchedItem>,
|
||||||
|
todayIsoDate: String,
|
||||||
|
preferFurthestEpisode: Boolean = true,
|
||||||
|
showUnairedNextUp: Boolean = false,
|
||||||
): SeriesPrimaryAction? =
|
): SeriesPrimaryAction? =
|
||||||
decideSeriesPrimaryAction(
|
decideSeriesPrimaryAction(
|
||||||
content = WatchingContentRef(type = type, id = id),
|
content = content,
|
||||||
episodes = videos.map(MetaVideo::toDomainReleasedEpisode),
|
episodes = videos.map(MetaVideo::toDomainReleasedEpisode),
|
||||||
progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord),
|
progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord),
|
||||||
watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord),
|
watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord),
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
preferFurthestEpisode = preferFurthestEpisode,
|
preferFurthestEpisode = preferFurthestEpisode,
|
||||||
|
showUnairedNextUp = showUnairedNextUp,
|
||||||
)?.toLegacySeriesPrimaryAction()
|
)?.toLegacySeriesPrimaryAction()
|
||||||
|
|
||||||
internal fun MetaVideo.playLabel(): String =
|
internal fun MetaVideo.playLabel(): String =
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ object DownloadsRepository {
|
||||||
): DownloadEnqueueResult {
|
): DownloadEnqueueResult {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
|
|
||||||
val sourceUrl = stream.directPlaybackUrl
|
val sourceUrl = stream.playableDirectUrl
|
||||||
?.trim()
|
?.trim()
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
?: return DownloadEnqueueResult.MissingUrl
|
?: return DownloadEnqueueResult.MissingUrl
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,15 @@ import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryRepository
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryUiState
|
||||||
|
import com.nuvio.app.features.cloud.findPlaybackTargetForProgress
|
||||||
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
|
import com.nuvio.app.features.details.SeriesPrimaryAction
|
||||||
|
import com.nuvio.app.features.details.seriesPrimaryAction
|
||||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||||
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
|
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
|
|
@ -44,6 +51,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
|
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
|
||||||
|
import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId
|
||||||
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
|
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
|
||||||
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
||||||
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
|
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
|
||||||
|
|
@ -51,14 +59,12 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal
|
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
|
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
|
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
|
|
||||||
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
|
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
|
||||||
import com.nuvio.app.features.watchprogress.continueWatchingEntries
|
import com.nuvio.app.features.watchprogress.continueWatchingEntries
|
||||||
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
||||||
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import com.nuvio.app.features.watching.domain.isReleasedBy
|
import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
|
|
@ -100,12 +106,16 @@ fun HomeScreen(
|
||||||
|
|
||||||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
|
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
val homeSettingsUiState by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val homeListState = rememberLazyListState()
|
val homeListState = rememberLazyListState()
|
||||||
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
||||||
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val traktSettingsUiState by remember {
|
val traktSettingsUiState by remember {
|
||||||
TraktSettingsRepository.ensureLoaded()
|
TraktSettingsRepository.ensureLoaded()
|
||||||
|
|
@ -167,47 +177,41 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
|
val allNextUpSeedCandidates = remember(
|
||||||
if (isTraktProgressActive) emptyList() else watchedUiState.items
|
|
||||||
}
|
|
||||||
|
|
||||||
val allNextUpSeedEntries = remember(
|
|
||||||
watchProgressUiState.entries,
|
watchProgressUiState.entries,
|
||||||
effectiveWatchedItems,
|
watchedUiState.items,
|
||||||
isTraktProgressActive,
|
isTraktProgressActive,
|
||||||
continueWatchingPreferences.upNextFromFurthestEpisode,
|
continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||||
) {
|
) {
|
||||||
buildTvParityNextUpSeedEntries(
|
buildHomeNextUpSeedCandidates(
|
||||||
progressEntries = watchProgressUiState.entries,
|
progressEntries = watchProgressUiState.entries,
|
||||||
watchedItems = effectiveWatchedItems,
|
watchedItems = watchedUiState.items,
|
||||||
isTraktProgressActive = isTraktProgressActive,
|
isTraktProgressActive = isTraktProgressActive,
|
||||||
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
|
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||||
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val recentNextUpSeedEntries = remember(
|
val recentNextUpSeedCandidates = remember(
|
||||||
allNextUpSeedEntries,
|
allNextUpSeedCandidates,
|
||||||
isTraktProgressActive,
|
isTraktProgressActive,
|
||||||
traktSettingsUiState.continueWatchingDaysCap,
|
traktSettingsUiState.continueWatchingDaysCap,
|
||||||
) {
|
) {
|
||||||
filterEntriesForTraktContinueWatchingWindow(
|
filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
|
||||||
entries = allNextUpSeedEntries,
|
candidates = allNextUpSeedCandidates,
|
||||||
isTraktProgressActive = isTraktProgressActive,
|
isTraktProgressActive = isTraktProgressActive,
|
||||||
daysCap = traktSettingsUiState.continueWatchingDaysCap,
|
daysCap = traktSettingsUiState.continueWatchingDaysCap,
|
||||||
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) {
|
val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) {
|
||||||
allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId }
|
allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) {
|
val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) {
|
||||||
allNextUpSeedEntries.mapNotNull { entry ->
|
allNextUpSeedCandidates.associate { candidate ->
|
||||||
val season = entry.seasonNumber ?: return@mapNotNull null
|
candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber)
|
||||||
val episode = entry.episodeNumber ?: return@mapNotNull null
|
|
||||||
entry.parentMetaId to (season to episode)
|
|
||||||
}.toMap()
|
}.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,10 +219,16 @@ fun HomeScreen(
|
||||||
effectiveWatchProgressEntries.continueWatchingEntries()
|
effectiveWatchProgressEntries.continueWatchingEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
val latestCompletedAtBySeries = remember(allNextUpSeedEntries) {
|
LaunchedEffect(visibleContinueWatchingEntries) {
|
||||||
allNextUpSeedEntries
|
if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) {
|
||||||
.groupBy { entry -> entry.parentMetaId }
|
CloudLibraryRepository.ensureLoaded()
|
||||||
.mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) {
|
||||||
|
allNextUpSeedCandidates
|
||||||
|
.groupBy { candidate -> candidate.content.id }
|
||||||
|
.mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE }
|
||||||
}
|
}
|
||||||
|
|
||||||
val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
|
val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
|
||||||
|
|
@ -236,17 +246,9 @@ fun HomeScreen(
|
||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) {
|
val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) {
|
||||||
recentNextUpSeedEntries.mapNotNull { seed ->
|
recentNextUpSeedCandidates.filter { candidate ->
|
||||||
val season = seed.seasonNumber ?: return@mapNotNull null
|
candidate.content.id !in nextUpSuppressedSeriesIds
|
||||||
val episode = seed.episodeNumber ?: return@mapNotNull null
|
|
||||||
if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null
|
|
||||||
CompletedSeriesCandidate(
|
|
||||||
content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId),
|
|
||||||
seasonNumber = season,
|
|
||||||
episodeNumber = episode,
|
|
||||||
markedAtEpochMs = seed.lastUpdatedEpochMs,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||||
|
|
@ -256,6 +258,17 @@ fun HomeScreen(
|
||||||
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
|
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
|
||||||
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
||||||
|
val shouldValidateMissingNextUpSeeds = remember(
|
||||||
|
isTraktProgressActive,
|
||||||
|
watchProgressUiState.hasLoadedRemoteProgress,
|
||||||
|
watchedUiState.isLoaded,
|
||||||
|
) {
|
||||||
|
if (isTraktProgressActive) {
|
||||||
|
watchProgressUiState.hasLoadedRemoteProgress
|
||||||
|
} else {
|
||||||
|
watchedUiState.isLoaded
|
||||||
|
}
|
||||||
|
}
|
||||||
val cachedNextUpItems = remember(
|
val cachedNextUpItems = remember(
|
||||||
cachedSnapshots.first,
|
cachedSnapshots.first,
|
||||||
continueWatchingPreferences.dismissedNextUpKeys,
|
continueWatchingPreferences.dismissedNextUpKeys,
|
||||||
|
|
@ -263,6 +276,7 @@ fun HomeScreen(
|
||||||
currentNextUpSeedByContentId,
|
currentNextUpSeedByContentId,
|
||||||
isTraktProgressActive,
|
isTraktProgressActive,
|
||||||
watchProgressUiState.hasLoadedRemoteProgress,
|
watchProgressUiState.hasLoadedRemoteProgress,
|
||||||
|
shouldValidateMissingNextUpSeeds,
|
||||||
processedNextUpContentIds,
|
processedNextUpContentIds,
|
||||||
nextUpItemsBySeries,
|
nextUpItemsBySeries,
|
||||||
continueWatchingPreferences.showUnairedNextUp,
|
continueWatchingPreferences.showUnairedNextUp,
|
||||||
|
|
@ -270,25 +284,13 @@ fun HomeScreen(
|
||||||
) {
|
) {
|
||||||
cachedSnapshots.first.mapNotNull { cached ->
|
cachedSnapshots.first.mapNotNull { cached ->
|
||||||
if (
|
if (
|
||||||
!isTraktProgressActive &&
|
shouldValidateMissingNextUpSeeds &&
|
||||||
watchedUiState.isLoaded &&
|
|
||||||
cached.contentId !in activeNextUpSeedContentIds
|
|
||||||
) {
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
isTraktProgressActive &&
|
|
||||||
watchProgressUiState.hasLoadedRemoteProgress &&
|
|
||||||
cached.contentId !in activeNextUpSeedContentIds
|
cached.contentId !in activeNextUpSeedContentIds
|
||||||
) {
|
) {
|
||||||
return@mapNotNull null
|
return@mapNotNull null
|
||||||
}
|
}
|
||||||
val currentSeed = currentNextUpSeedByContentId[cached.contentId]
|
val currentSeed = currentNextUpSeedByContentId[cached.contentId]
|
||||||
if (
|
if (currentSeed != null) {
|
||||||
currentSeed != null &&
|
|
||||||
cached.seedSeason != null &&
|
|
||||||
cached.seedEpisode != null
|
|
||||||
) {
|
|
||||||
val (currentSeason, currentEpisode) = currentSeed
|
val (currentSeason, currentEpisode) = currentSeed
|
||||||
val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode
|
val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode
|
||||||
if (seedChanged) return@mapNotNull null
|
if (seedChanged) return@mapNotNull null
|
||||||
|
|
@ -321,8 +323,16 @@ fun HomeScreen(
|
||||||
nextUpItemsBySeries,
|
nextUpItemsBySeries,
|
||||||
cachedNextUpItems,
|
cachedNextUpItems,
|
||||||
continueWatchingPreferences.dismissedNextUpKeys,
|
continueWatchingPreferences.dismissedNextUpKeys,
|
||||||
|
activeNextUpSeedContentIds,
|
||||||
|
currentNextUpSeedByContentId,
|
||||||
|
shouldValidateMissingNextUpSeeds,
|
||||||
) {
|
) {
|
||||||
val liveNextUpItems = nextUpItemsBySeries.filterValues { (_, item) ->
|
val liveNextUpItems = filterNextUpItemsByCurrentSeeds(
|
||||||
|
nextUpItemsBySeries = nextUpItemsBySeries,
|
||||||
|
activeSeedContentIds = activeNextUpSeedContentIds,
|
||||||
|
currentSeedByContentId = currentNextUpSeedByContentId,
|
||||||
|
shouldDropItemsWithoutActiveSeed = shouldValidateMissingNextUpSeeds,
|
||||||
|
).filterValues { (_, item) ->
|
||||||
nextUpDismissKey(
|
nextUpDismissKey(
|
||||||
item.parentMetaId,
|
item.parentMetaId,
|
||||||
item.nextUpSeedSeasonNumber,
|
item.nextUpSeedSeasonNumber,
|
||||||
|
|
@ -345,6 +355,7 @@ fun HomeScreen(
|
||||||
effectivNextUpItems,
|
effectivNextUpItems,
|
||||||
nextUpSuppressedSeriesIds,
|
nextUpSuppressedSeriesIds,
|
||||||
continueWatchingPreferences.sortMode,
|
continueWatchingPreferences.sortMode,
|
||||||
|
cloudLibraryUiState,
|
||||||
) {
|
) {
|
||||||
buildHomeContinueWatchingItems(
|
buildHomeContinueWatchingItems(
|
||||||
visibleEntries = visibleContinueWatchingEntries,
|
visibleEntries = visibleContinueWatchingEntries,
|
||||||
|
|
@ -353,6 +364,7 @@ fun HomeScreen(
|
||||||
nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds,
|
nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds,
|
||||||
sortMode = continueWatchingPreferences.sortMode,
|
sortMode = continueWatchingPreferences.sortMode,
|
||||||
todayIsoDate = CurrentDateProvider.todayIsoDate(),
|
todayIsoDate = CurrentDateProvider.todayIsoDate(),
|
||||||
|
cloudLibraryUiState = cloudLibraryUiState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val availableManifests = remember(addonsUiState.addons) {
|
val availableManifests = remember(addonsUiState.addons) {
|
||||||
|
|
@ -396,6 +408,9 @@ fun HomeScreen(
|
||||||
visibleContinueWatchingEntries,
|
visibleContinueWatchingEntries,
|
||||||
metaProviderKey,
|
metaProviderKey,
|
||||||
continueWatchingPreferences.showUnairedNextUp,
|
continueWatchingPreferences.showUnairedNextUp,
|
||||||
|
continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||||
|
watchProgressUiState.entries,
|
||||||
|
watchedUiState.items,
|
||||||
) {
|
) {
|
||||||
if (completedSeriesCandidates.isEmpty()) {
|
if (completedSeriesCandidates.isEmpty()) {
|
||||||
nextUpItemsBySeries = emptyMap()
|
nextUpItemsBySeries = emptyMap()
|
||||||
|
|
@ -446,12 +461,18 @@ fun HomeScreen(
|
||||||
if (meta == null) {
|
if (meta == null) {
|
||||||
return@withPermit null
|
return@withPermit null
|
||||||
}
|
}
|
||||||
val nextEpisode = meta.nextReleasedEpisodeAfter(
|
val action = meta.seriesPrimaryAction(
|
||||||
seasonNumber = completedEntry.seasonNumber,
|
content = completedEntry.content,
|
||||||
episodeNumber = completedEntry.episodeNumber,
|
entries = watchProgressUiState.entries,
|
||||||
|
watchedItems = watchedUiState.items,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
|
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||||
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
|
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
|
||||||
)
|
)
|
||||||
|
if (action?.resumePositionMs != null) {
|
||||||
|
return@withPermit null
|
||||||
|
}
|
||||||
|
val nextEpisode = action?.let { meta.videoForSeriesAction(it) }
|
||||||
if (nextEpisode == null) {
|
if (nextEpisode == null) {
|
||||||
return@withPermit null
|
return@withPermit null
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +633,10 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(3) {
|
items(3) {
|
||||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
|
HomeSkeletonRow(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
showHeaderAccent = !homeSettingsUiState.hideCatalogUnderline,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -717,40 +741,99 @@ internal fun filterEntriesForTraktContinueWatchingWindow(
|
||||||
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildTvParityNextUpSeedEntries(
|
internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
|
||||||
|
candidates: List<CompletedSeriesCandidate>,
|
||||||
|
isTraktProgressActive: Boolean,
|
||||||
|
daysCap: Int,
|
||||||
|
nowEpochMs: Long,
|
||||||
|
): List<CompletedSeriesCandidate> {
|
||||||
|
if (!isTraktProgressActive) return candidates
|
||||||
|
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
|
||||||
|
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates
|
||||||
|
|
||||||
|
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
|
||||||
|
return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildHomeNextUpSeedCandidates(
|
||||||
progressEntries: List<WatchProgressEntry>,
|
progressEntries: List<WatchProgressEntry>,
|
||||||
watchedItems: List<WatchedItem>,
|
watchedItems: List<WatchedItem>,
|
||||||
isTraktProgressActive: Boolean,
|
isTraktProgressActive: Boolean,
|
||||||
preferFurthestEpisode: Boolean,
|
preferFurthestEpisode: Boolean,
|
||||||
nowEpochMs: Long,
|
nowEpochMs: Long,
|
||||||
): List<WatchProgressEntry> {
|
): List<CompletedSeriesCandidate> {
|
||||||
val rawSeeds = if (isTraktProgressActive) {
|
val progressSeeds = progressEntries
|
||||||
progressEntries.asSequence()
|
.asSequence()
|
||||||
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||||
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
|
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
|
||||||
.filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) }
|
.filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) }
|
||||||
.toList()
|
.filter { entry ->
|
||||||
} else {
|
if (isTraktProgressActive) {
|
||||||
watchedItems.asSequence()
|
shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs)
|
||||||
.filter { item -> item.type.isSeriesTypeForContinueWatching() }
|
} else {
|
||||||
.filter { item -> item.season != null && item.episode != null && item.season != 0 }
|
entry.shouldUseAsCompletedSeedForContinueWatching()
|
||||||
.filter { item -> !isMalformedNextUpSeedContentId(item.id) }
|
}
|
||||||
.map { item -> item.toNextUpSeedEntry() }
|
}
|
||||||
.toList()
|
.toList()
|
||||||
|
val watchedSeeds = watchedItems.filter { item ->
|
||||||
|
item.type.isSeriesTypeForContinueWatching() &&
|
||||||
|
item.season != null &&
|
||||||
|
item.episode != null &&
|
||||||
|
item.season != 0 &&
|
||||||
|
!isMalformedNextUpSeedContentId(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (isTraktProgressActive) {
|
return WatchingState.latestCompletedBySeries(
|
||||||
mergeTvTraktNextUpSeeds(rawSeeds)
|
progressEntries = progressSeeds,
|
||||||
} else {
|
watchedItems = watchedSeeds,
|
||||||
rawSeeds
|
preferFurthestEpisode = preferFurthestEpisode,
|
||||||
.groupBy { entry -> nextUpSeedKey(entry) }
|
).mapNotNull { (content, completed) ->
|
||||||
.mapNotNull { (_, entries) ->
|
if (!content.type.isSeriesTypeForContinueWatching()) return@mapNotNull null
|
||||||
choosePreferredNextUpSeed(
|
if (completed.seasonNumber == 0) return@mapNotNull null
|
||||||
entries = entries,
|
if (isMalformedNextUpSeedContentId(content.id)) return@mapNotNull null
|
||||||
preferFurthestEpisode = preferFurthestEpisode,
|
CompletedSeriesCandidate(
|
||||||
)
|
content = content,
|
||||||
}
|
seasonNumber = completed.seasonNumber,
|
||||||
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
episodeNumber = completed.episodeNumber,
|
||||||
|
markedAtEpochMs = completed.markedAtEpochMs,
|
||||||
|
)
|
||||||
|
}.sortedWith(
|
||||||
|
compareByDescending<CompletedSeriesCandidate> { candidate -> candidate.markedAtEpochMs }
|
||||||
|
.thenByDescending { candidate -> candidate.seasonNumber }
|
||||||
|
.thenByDescending { candidate -> candidate.episodeNumber },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun filterNextUpItemsByCurrentSeeds(
|
||||||
|
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||||
|
activeSeedContentIds: Set<String>,
|
||||||
|
currentSeedByContentId: Map<String, Pair<Int, Int>>,
|
||||||
|
shouldDropItemsWithoutActiveSeed: Boolean,
|
||||||
|
): Map<String, Pair<Long, ContinueWatchingItem>> =
|
||||||
|
nextUpItemsBySeries.filter { (contentId, pair) ->
|
||||||
|
if (shouldDropItemsWithoutActiveSeed && contentId !in activeSeedContentIds) {
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
val item = pair.second
|
||||||
|
val currentSeed = currentSeedByContentId[contentId] ?: return@filter true
|
||||||
|
item.nextUpSeedSeasonNumber == currentSeed.first &&
|
||||||
|
item.nextUpSeedEpisodeNumber == currentSeed.second
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? {
|
||||||
|
if (action.seasonNumber != null && action.episodeNumber != null) {
|
||||||
|
videos.firstOrNull { video ->
|
||||||
|
video.season == action.seasonNumber &&
|
||||||
|
video.episode == action.episodeNumber
|
||||||
|
}?.let { return it }
|
||||||
|
}
|
||||||
|
return videos.firstOrNull { video ->
|
||||||
|
com.nuvio.app.features.watchprogress.buildPlaybackVideoId(
|
||||||
|
parentMetaId = id,
|
||||||
|
seasonNumber = video.season,
|
||||||
|
episodeNumber = video.episode,
|
||||||
|
fallbackVideoId = video.id,
|
||||||
|
) == action.videoId || video.id == action.videoId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -765,103 +848,6 @@ private fun shouldUseAsTraktNextUpSeed(
|
||||||
return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS
|
return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry =
|
|
||||||
WatchProgressEntry(
|
|
||||||
contentType = type,
|
|
||||||
parentMetaId = id,
|
|
||||||
parentMetaType = type,
|
|
||||||
videoId = id,
|
|
||||||
title = name,
|
|
||||||
poster = poster,
|
|
||||||
seasonNumber = season,
|
|
||||||
episodeNumber = episode,
|
|
||||||
lastPositionMs = 1L,
|
|
||||||
durationMs = 1L,
|
|
||||||
lastUpdatedEpochMs = markedAtEpochMs,
|
|
||||||
isCompleted = true,
|
|
||||||
progressPercent = 100f,
|
|
||||||
source = WatchProgressSourceLocal,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun nextUpSeedKey(entry: WatchProgressEntry): String =
|
|
||||||
entry.parentMetaId.trim()
|
|
||||||
|
|
||||||
private fun mergeTvTraktNextUpSeeds(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
|
|
||||||
val merged = linkedMapOf<String, WatchProgressEntry>()
|
|
||||||
entries
|
|
||||||
.filter { entry -> entry.source == WatchProgressSourceTraktShowProgress }
|
|
||||||
.forEach { seed ->
|
|
||||||
merged[nextUpSeedKey(seed)] = seed
|
|
||||||
}
|
|
||||||
entries
|
|
||||||
.filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback }
|
|
||||||
.forEach { seed ->
|
|
||||||
val key = nextUpSeedKey(seed)
|
|
||||||
val existing = merged[key]
|
|
||||||
if (existing == null || shouldReplaceNextUpSeed(existing, seed)) {
|
|
||||||
merged[key] = seed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldReplaceNextUpSeed(
|
|
||||||
existing: WatchProgressEntry,
|
|
||||||
candidate: WatchProgressEntry,
|
|
||||||
): Boolean {
|
|
||||||
val candidateSeason = candidate.seasonNumber ?: -1
|
|
||||||
val candidateEpisode = candidate.episodeNumber ?: -1
|
|
||||||
val existingSeason = existing.seasonNumber ?: -1
|
|
||||||
val existingEpisode = existing.episodeNumber ?: -1
|
|
||||||
return candidateSeason > existingSeason ||
|
|
||||||
(
|
|
||||||
candidateSeason == existingSeason &&
|
|
||||||
(
|
|
||||||
candidateEpisode > existingEpisode ||
|
|
||||||
(
|
|
||||||
candidateEpisode == existingEpisode &&
|
|
||||||
candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun choosePreferredNextUpSeed(
|
|
||||||
entries: List<WatchProgressEntry>,
|
|
||||||
preferFurthestEpisode: Boolean,
|
|
||||||
): WatchProgressEntry? {
|
|
||||||
if (entries.isEmpty()) return null
|
|
||||||
val bestRank = entries.minOf(::nextUpSeedSourceRank)
|
|
||||||
return entries
|
|
||||||
.asSequence()
|
|
||||||
.filter { entry -> nextUpSeedSourceRank(entry) == bestRank }
|
|
||||||
.maxWithOrNull(
|
|
||||||
if (preferFurthestEpisode) {
|
|
||||||
compareBy<WatchProgressEntry>(
|
|
||||||
{ it.seasonNumber ?: -1 },
|
|
||||||
{ it.episodeNumber ?: -1 },
|
|
||||||
{ it.lastUpdatedEpochMs },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
compareBy<WatchProgressEntry>(
|
|
||||||
{ it.lastUpdatedEpochMs },
|
|
||||||
{ it.seasonNumber ?: -1 },
|
|
||||||
{ it.episodeNumber ?: -1 },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int =
|
|
||||||
when (entry.source) {
|
|
||||||
WatchProgressSourceTraktPlayback,
|
|
||||||
WatchProgressSourceTraktShowProgress,
|
|
||||||
-> 0
|
|
||||||
WatchProgressSourceTraktHistory -> 1
|
|
||||||
WatchProgressSourceLocal -> 2
|
|
||||||
else -> 4
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldTreatAsActiveInProgressForNextUpSuppression(
|
private fun shouldTreatAsActiveInProgressForNextUpSuppression(
|
||||||
progress: WatchProgressEntry,
|
progress: WatchProgressEntry,
|
||||||
latestCompletedAt: Long?,
|
latestCompletedAt: Long?,
|
||||||
|
|
@ -871,15 +857,6 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression(
|
||||||
return progress.lastUpdatedEpochMs >= latestCompletedAt
|
return progress.lastUpdatedEpochMs >= latestCompletedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
|
|
||||||
val trimmed = contentId?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return true
|
|
||||||
return when (trimmed.lowercase()) {
|
|
||||||
"tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun heroMobileBelowSectionHeightHint(
|
private fun heroMobileBelowSectionHeightHint(
|
||||||
maxWidthDp: Float,
|
maxWidthDp: Float,
|
||||||
continueWatchingVisible: Boolean,
|
continueWatchingVisible: Boolean,
|
||||||
|
|
@ -905,6 +882,7 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
nextUpSuppressedSeriesIds: Set<String>? = null,
|
nextUpSuppressedSeriesIds: Set<String>? = null,
|
||||||
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
|
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
|
||||||
todayIsoDate: String = "",
|
todayIsoDate: String = "",
|
||||||
|
cloudLibraryUiState: CloudLibraryUiState? = null,
|
||||||
): List<ContinueWatchingItem> {
|
): List<ContinueWatchingItem> {
|
||||||
val suppressedSeriesIds = nextUpSuppressedSeriesIds
|
val suppressedSeriesIds = nextUpSuppressedSeriesIds
|
||||||
?: visibleEntries
|
?: visibleEntries
|
||||||
|
|
@ -920,7 +898,9 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
val liveItem = entry.toContinueWatchingItem()
|
val liveItem = entry.toContinueWatchingItem()
|
||||||
HomeContinueWatchingCandidate(
|
HomeContinueWatchingCandidate(
|
||||||
lastUpdatedEpochMs = entry.lastUpdatedEpochMs,
|
lastUpdatedEpochMs = entry.lastUpdatedEpochMs,
|
||||||
item = liveItem.withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]),
|
item = liveItem
|
||||||
|
.withFallbackMetadata(cachedInProgressByVideoId[entry.videoId])
|
||||||
|
.withCloudLibraryMetadata(cloudLibraryUiState),
|
||||||
isProgressEntry = true,
|
isProgressEntry = true,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -994,7 +974,7 @@ private fun applyStreamingStyleSort(
|
||||||
return sortedReleased + sortedUnreleased
|
return sortedReleased + sortedUnreleased
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class CompletedSeriesCandidate(
|
internal data class CompletedSeriesCandidate(
|
||||||
val content: WatchingContentRef,
|
val content: WatchingContentRef,
|
||||||
val seasonNumber: Int,
|
val seasonNumber: Int,
|
||||||
val episodeNumber: Int,
|
val episodeNumber: Int,
|
||||||
|
|
@ -1160,9 +1140,16 @@ private fun ContinueWatchingItem.withFallbackMetadata(
|
||||||
fallback: ContinueWatchingItem?,
|
fallback: ContinueWatchingItem?,
|
||||||
): ContinueWatchingItem {
|
): ContinueWatchingItem {
|
||||||
if (fallback == null) return this
|
if (fallback == null) return this
|
||||||
|
val fallbackTitle = fallback.title
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?.takeUnless { fallback.hasPlaceholderCloudTitle() }
|
||||||
|
|
||||||
return copy(
|
return copy(
|
||||||
title = title.ifBlank { fallback.title },
|
title = when {
|
||||||
|
title.isBlank() -> fallback.title
|
||||||
|
hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle
|
||||||
|
else -> title
|
||||||
|
},
|
||||||
subtitle = subtitle.ifBlank { fallback.subtitle },
|
subtitle = subtitle.ifBlank { fallback.subtitle },
|
||||||
imageUrl = imageUrl ?: fallback.imageUrl,
|
imageUrl = imageUrl ?: fallback.imageUrl,
|
||||||
logo = logo ?: fallback.logo,
|
logo = logo ?: fallback.logo,
|
||||||
|
|
@ -1174,3 +1161,35 @@ private fun ContinueWatchingItem.withFallbackMetadata(
|
||||||
released = released ?: fallback.released,
|
released = released ?: fallback.released,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ContinueWatchingItem.withCloudLibraryMetadata(
|
||||||
|
cloudLibraryUiState: CloudLibraryUiState?,
|
||||||
|
): ContinueWatchingItem {
|
||||||
|
if (!isCloudLibraryContinueWatchingItem() || cloudLibraryUiState == null) return this
|
||||||
|
val target = cloudLibraryUiState.findPlaybackTargetForProgress(
|
||||||
|
contentId = parentMetaId,
|
||||||
|
videoId = videoId,
|
||||||
|
) ?: return this
|
||||||
|
val fileName = target.file.name.trim().takeIf { it.isNotBlank() }
|
||||||
|
?: target.item.name.trim().takeIf { it.isNotBlank() }
|
||||||
|
?: return this
|
||||||
|
return copy(
|
||||||
|
title = fileName,
|
||||||
|
pauseDescription = pauseDescription
|
||||||
|
?: target.item.name.takeIf { itemName -> itemName.isNotBlank() && itemName != fileName },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean {
|
||||||
|
if (!isCloudLibraryContinueWatchingItem()) return false
|
||||||
|
val normalizedTitle = title.trim()
|
||||||
|
return normalizedTitle.equals(parentMetaId, ignoreCase = true) ||
|
||||||
|
normalizedTitle.equals(videoId, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean =
|
||||||
|
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
|
||||||
|
|
||||||
|
private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean =
|
||||||
|
contentType.equals(CloudLibraryContentType, ignoreCase = true) ||
|
||||||
|
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.core.ui.posterCardClickable
|
import com.nuvio.app.core.ui.posterCardClickable
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||||
|
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||||
|
|
@ -64,10 +66,15 @@ private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): Strin
|
||||||
stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber)
|
stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber)
|
||||||
item.isNextUp ->
|
item.isNextUp ->
|
||||||
stringResource(Res.string.continue_watching_up_next)
|
stringResource(Res.string.continue_watching_up_next)
|
||||||
|
item.isCloudLibraryItem() ->
|
||||||
|
stringResource(Res.string.library_source_cloud)
|
||||||
else ->
|
else ->
|
||||||
stringResource(Res.string.media_movie)
|
stringResource(Res.string.media_movie)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean =
|
||||||
|
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
|
||||||
|
|
||||||
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
|
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
|
||||||
useEpisodeThumbnails: Boolean,
|
useEpisodeThumbnails: Boolean,
|
||||||
): String? = when {
|
): String? = when {
|
||||||
|
|
@ -392,6 +399,7 @@ private fun ContinueWatchingWideCard(
|
||||||
imageUrl = artworkUrl,
|
imageUrl = artworkUrl,
|
||||||
width = layout.widePosterStripWidth,
|
width = layout.widePosterStripWidth,
|
||||||
blurred = shouldBlurArtwork,
|
blurred = shouldBlurArtwork,
|
||||||
|
contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -504,12 +512,12 @@ private fun ContinueWatchingPosterCard(
|
||||||
val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = cloudLibraryDisplayArtworkUrl(imageUrl),
|
||||||
contentDescription = item.title,
|
contentDescription = item.title,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) {
|
if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) {
|
||||||
|
|
@ -589,6 +597,7 @@ private fun ArtworkPanel(
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
width: Dp,
|
width: Dp,
|
||||||
blurred: Boolean = false,
|
blurred: Boolean = false,
|
||||||
|
contentScale: ContentScale = ContentScale.Crop,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -598,12 +607,12 @@ private fun ArtworkPanel(
|
||||||
) {
|
) {
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = cloudLibraryDisplayArtworkUrl(imageUrl),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
|
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,10 @@ fun HomeSkeletonHero(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeSkeletonRow(modifier: Modifier = Modifier) {
|
fun HomeSkeletonRow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showHeaderAccent: Boolean = true,
|
||||||
|
) {
|
||||||
val brush = rememberHomeSkeletonBrush()
|
val brush = rememberHomeSkeletonBrush()
|
||||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||||
val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) {
|
val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) {
|
||||||
|
|
@ -207,15 +210,17 @@ fun HomeSkeletonRow(modifier: Modifier = Modifier) {
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
.background(brush),
|
.background(brush),
|
||||||
)
|
)
|
||||||
// Accent bar
|
if (showHeaderAccent) {
|
||||||
Box(
|
// Accent bar
|
||||||
modifier = Modifier
|
Box(
|
||||||
.width(60.dp)
|
modifier = Modifier
|
||||||
.height(4.dp)
|
.width(60.dp)
|
||||||
.clip(RoundedCornerShape(999.dp))
|
.height(4.dp)
|
||||||
.background(brush),
|
.clip(RoundedCornerShape(999.dp))
|
||||||
)
|
.background(brush),
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
}
|
||||||
// Poster row
|
// Poster row
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,78 @@
|
||||||
package com.nuvio.app.features.library
|
package com.nuvio.app.features.library
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.nuvio.app.core.i18n.localizedByteUnit
|
||||||
import com.nuvio.app.core.network.NetworkCondition
|
import com.nuvio.app.core.network.NetworkCondition
|
||||||
import com.nuvio.app.core.network.NetworkStatusRepository
|
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||||
|
import com.nuvio.app.core.ui.NuvioDropdownChip
|
||||||
|
import com.nuvio.app.core.ui.NuvioDropdownOption
|
||||||
import com.nuvio.app.core.ui.NuvioScreen
|
import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
|
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryFile
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryItem
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryItemType
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryRepository
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryUiState
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
import com.nuvio.app.features.home.components.HomePosterCard
|
import com.nuvio.app.features.home.components.HomePosterCard
|
||||||
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||||
|
|
@ -47,17 +92,38 @@ fun LibraryScreen(
|
||||||
onPosterClick: ((LibraryItem) -> Unit)? = null,
|
onPosterClick: ((LibraryItem) -> Unit)? = null,
|
||||||
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
|
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
|
||||||
onSectionViewAllClick: ((LibrarySection) -> Unit)? = null,
|
onSectionViewAllClick: ((LibrarySection) -> Unit)? = null,
|
||||||
|
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
|
||||||
|
onConnectCloudClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val uiState by remember {
|
val uiState by remember {
|
||||||
LibraryRepository.ensureLoaded()
|
LibraryRepository.ensureLoaded()
|
||||||
LibraryRepository.uiState
|
LibraryRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val cloudUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val cloudSettings by remember {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
DebridSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val watchedUiState by remember {
|
val watchedUiState by remember {
|
||||||
WatchedRepository.ensureLoaded()
|
WatchedRepository.ensureLoaded()
|
||||||
WatchedRepository.uiState
|
WatchedRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val homeCatalogSettingsUiState by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
var observedOfflineState by remember { mutableStateOf(false) }
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
|
var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) }
|
||||||
|
val sourceMode = remember(sourceModeName) {
|
||||||
|
runCatching { LibraryViewMode.valueOf(sourceModeName) }.getOrDefault(LibraryViewMode.Saved)
|
||||||
|
}
|
||||||
|
var selectedProviderId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var selectedTypeName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val selectedType = remember(selectedTypeName) {
|
||||||
|
selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() }
|
||||||
|
}
|
||||||
|
var selectedCloudItemKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
||||||
|
|
@ -98,6 +164,13 @@ fun LibraryScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(sourceMode, cloudSettings.cloudLibraryEnabled, cloudSettings.providerApiKeys) {
|
||||||
|
if (sourceMode == LibraryViewMode.Cloud) {
|
||||||
|
CloudLibraryRepository.ensureLoaded()
|
||||||
|
selectedCloudItemKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
NuvioScreen(
|
NuvioScreen(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalPadding = 0.dp,
|
horizontalPadding = 0.dp,
|
||||||
|
|
@ -111,90 +184,806 @@ fun LibraryScreen(
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
) {
|
) {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = if (isTraktSource) {
|
title = if (sourceMode == LibraryViewMode.Cloud) {
|
||||||
|
stringResource(Res.string.library_title)
|
||||||
|
} else if (isTraktSource) {
|
||||||
stringResource(Res.string.library_trakt_title)
|
stringResource(Res.string.library_trakt_title)
|
||||||
} else {
|
} else {
|
||||||
stringResource(Res.string.library_title)
|
stringResource(Res.string.library_title)
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
|
LibrarySourceSwitch(
|
||||||
|
selectedMode = sourceMode,
|
||||||
|
onModeSelected = { mode ->
|
||||||
|
sourceModeName = mode.name
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when {
|
if (sourceMode == LibraryViewMode.Cloud) {
|
||||||
!uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
|
cloudLibraryContent(
|
||||||
items(3) {
|
uiState = cloudUiState,
|
||||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
|
selectedProviderId = selectedProviderId,
|
||||||
|
selectedType = selectedType,
|
||||||
|
selectedCloudItemKey = selectedCloudItemKey,
|
||||||
|
onProviderSelected = {
|
||||||
|
selectedProviderId = it
|
||||||
|
selectedTypeName = null
|
||||||
|
selectedCloudItemKey = null
|
||||||
|
},
|
||||||
|
onTypeSelected = {
|
||||||
|
selectedTypeName = it?.name
|
||||||
|
selectedCloudItemKey = null
|
||||||
|
},
|
||||||
|
onItemSelected = { item ->
|
||||||
|
val playableFiles = item.playableFiles
|
||||||
|
when {
|
||||||
|
playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first())
|
||||||
|
playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) },
|
||||||
|
onBackToItems = { selectedCloudItemKey = null },
|
||||||
|
onRefresh = { CloudLibraryRepository.refresh() },
|
||||||
|
onConnectCloudClick = onConnectCloudClick,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
!uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
|
||||||
|
items(3) {
|
||||||
|
HomeSkeletonRow(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
|
||||||
|
item {
|
||||||
|
if (networkStatusUiState.isOfflineLike) {
|
||||||
|
NuvioNetworkOfflineCard(
|
||||||
|
condition = networkStatusUiState.condition,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
onRetry = retryLibraryLoad,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
HomeEmptyStateCard(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
title = if (isTraktSource) {
|
||||||
|
stringResource(Res.string.library_trakt_load_failed)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.library_load_failed)
|
||||||
|
},
|
||||||
|
message = uiState.errorMessage.orEmpty(),
|
||||||
|
actionLabel = stringResource(Res.string.action_retry),
|
||||||
|
onActionClick = retryLibraryLoad,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.sections.isEmpty() -> {
|
||||||
|
item {
|
||||||
|
if (networkStatusUiState.isOfflineLike && isTraktSource) {
|
||||||
|
NuvioNetworkOfflineCard(
|
||||||
|
condition = networkStatusUiState.condition,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
onRetry = retryLibraryLoad,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
HomeEmptyStateCard(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
title = if (isTraktSource) {
|
||||||
|
stringResource(Res.string.library_trakt_empty_title)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.library_empty_title)
|
||||||
|
},
|
||||||
|
message = if (isTraktSource) {
|
||||||
|
stringResource(Res.string.library_trakt_empty_message)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.library_empty_message)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
librarySections(
|
||||||
|
sections = uiState.sections,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||||
|
onPosterClick = onPosterClick,
|
||||||
|
onSectionViewAllClick = onSectionViewAllClick,
|
||||||
|
onPosterLongClick = onPosterLongClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
!uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
|
private fun LazyListScope.cloudLibraryContent(
|
||||||
|
uiState: CloudLibraryUiState,
|
||||||
|
selectedProviderId: String?,
|
||||||
|
selectedType: CloudLibraryItemType?,
|
||||||
|
selectedCloudItemKey: String?,
|
||||||
|
onProviderSelected: (String?) -> Unit,
|
||||||
|
onTypeSelected: (CloudLibraryItemType?) -> Unit,
|
||||||
|
onItemSelected: (CloudLibraryItem) -> Unit,
|
||||||
|
onFileSelected: (CloudLibraryItem, CloudLibraryFile) -> Unit,
|
||||||
|
onBackToItems: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onConnectCloudClick: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
!uiState.isLoaded -> {
|
||||||
|
cloudLibrarySkeletonItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
!uiState.isEnabled -> {
|
||||||
|
item {
|
||||||
|
HomeEmptyStateCard(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
title = stringResource(Res.string.cloud_library_disabled_title),
|
||||||
|
message = stringResource(Res.string.cloud_library_disabled_message),
|
||||||
|
actionLabel = stringResource(Res.string.cloud_library_disabled_action),
|
||||||
|
onActionClick = onConnectCloudClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!uiState.hasConnectedProvider -> {
|
||||||
|
item {
|
||||||
|
HomeEmptyStateCard(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
title = stringResource(Res.string.cloud_library_connect_title),
|
||||||
|
message = stringResource(Res.string.cloud_library_connect_message),
|
||||||
|
actionLabel = stringResource(Res.string.cloud_library_connect_action),
|
||||||
|
onActionClick = onConnectCloudClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val providerItems = uiState.items
|
||||||
|
.filter { item -> selectedProviderId == null || item.providerId == selectedProviderId }
|
||||||
|
val availableTypes = providerItems
|
||||||
|
.map { item -> item.type }
|
||||||
|
.distinct()
|
||||||
|
.sortedBy { type -> type.ordinal }
|
||||||
|
val effectiveSelectedType = selectedType?.takeIf { type -> type in availableTypes }
|
||||||
|
val filteredItems = providerItems
|
||||||
|
.filter { item -> effectiveSelectedType == null || item.type == effectiveSelectedType }
|
||||||
|
val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey }
|
||||||
|
|
||||||
|
if (selectedItem != null) {
|
||||||
item {
|
item {
|
||||||
if (networkStatusUiState.isOfflineLike) {
|
CloudLibraryFilePicker(
|
||||||
NuvioNetworkOfflineCard(
|
item = selectedItem,
|
||||||
condition = networkStatusUiState.condition,
|
onBack = onBackToItems,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
onFileSelected = { file -> onFileSelected(selectedItem, file) },
|
||||||
onRetry = retryLibraryLoad,
|
)
|
||||||
)
|
}
|
||||||
} else {
|
} else {
|
||||||
|
item {
|
||||||
|
CloudLibraryToolbar(
|
||||||
|
uiState = uiState,
|
||||||
|
selectedProviderId = selectedProviderId,
|
||||||
|
selectedType = effectiveSelectedType,
|
||||||
|
availableTypes = availableTypes,
|
||||||
|
onProviderSelected = onProviderSelected,
|
||||||
|
onTypeSelected = onTypeSelected,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.providers
|
||||||
|
.filter { providerState -> selectedProviderId == null || providerState.providerId == selectedProviderId }
|
||||||
|
.filter { providerState -> !providerState.errorMessage.isNullOrBlank() && providerState.items.isEmpty() }
|
||||||
|
.forEach { providerState ->
|
||||||
|
item(key = "cloud-error-${providerState.providerId}") {
|
||||||
|
HomeEmptyStateCard(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
title = stringResource(Res.string.cloud_library_load_failed, providerState.providerName),
|
||||||
|
message = providerState.errorMessage.orEmpty(),
|
||||||
|
actionLabel = stringResource(Res.string.action_retry),
|
||||||
|
onActionClick = onRefresh,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.isRefreshing && filteredItems.isEmpty()) {
|
||||||
|
cloudLibrarySkeletonItems()
|
||||||
|
} else if (filteredItems.isEmpty()) {
|
||||||
|
item {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
title = if (isTraktSource) {
|
title = stringResource(Res.string.cloud_library_empty_title),
|
||||||
stringResource(Res.string.library_trakt_load_failed)
|
message = stringResource(Res.string.cloud_library_empty_message),
|
||||||
} else {
|
|
||||||
stringResource(Res.string.library_load_failed)
|
|
||||||
},
|
|
||||||
message = uiState.errorMessage.orEmpty(),
|
|
||||||
actionLabel = stringResource(Res.string.action_retry),
|
actionLabel = stringResource(Res.string.action_retry),
|
||||||
onActionClick = retryLibraryLoad,
|
onActionClick = onRefresh,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(
|
||||||
|
items = filteredItems,
|
||||||
|
key = { item -> item.stableKey },
|
||||||
|
) { item ->
|
||||||
|
CloudLibraryRow(
|
||||||
|
item = item,
|
||||||
|
onClick = { onItemSelected(item) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uiState.sections.isEmpty() -> {
|
private fun LazyListScope.cloudLibrarySkeletonItems() {
|
||||||
item {
|
item(key = "cloud-library-skeleton-toolbar") {
|
||||||
if (networkStatusUiState.isOfflineLike && isTraktSource) {
|
CloudLibrarySkeletonToolbar(
|
||||||
NuvioNetworkOfflineCard(
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
condition = networkStatusUiState.condition,
|
)
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
}
|
||||||
onRetry = retryLibraryLoad,
|
items(3) {
|
||||||
)
|
CloudLibrarySkeletonRow()
|
||||||
} else {
|
}
|
||||||
HomeEmptyStateCard(
|
}
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
title = if (isTraktSource) {
|
@Composable
|
||||||
stringResource(Res.string.library_trakt_empty_title)
|
private fun LibrarySourceSwitch(
|
||||||
} else {
|
selectedMode: LibraryViewMode,
|
||||||
stringResource(Res.string.library_empty_title)
|
onModeSelected: (LibraryViewMode) -> Unit,
|
||||||
},
|
modifier: Modifier = Modifier,
|
||||||
message = if (isTraktSource) {
|
) {
|
||||||
stringResource(Res.string.library_trakt_empty_message)
|
Row(
|
||||||
} else {
|
modifier = modifier.fillMaxWidth(),
|
||||||
stringResource(Res.string.library_empty_message)
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
},
|
) {
|
||||||
)
|
LibraryChip(
|
||||||
}
|
label = stringResource(Res.string.library_source_saved),
|
||||||
}
|
selected = selectedMode == LibraryViewMode.Saved,
|
||||||
|
onClick = { onModeSelected(LibraryViewMode.Saved) },
|
||||||
|
)
|
||||||
|
LibraryChip(
|
||||||
|
label = stringResource(Res.string.library_source_cloud),
|
||||||
|
selected = selectedMode == LibraryViewMode.Cloud,
|
||||||
|
onClick = { onModeSelected(LibraryViewMode.Cloud) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CloudLibraryToolbar(
|
||||||
|
uiState: CloudLibraryUiState,
|
||||||
|
selectedProviderId: String?,
|
||||||
|
selectedType: CloudLibraryItemType?,
|
||||||
|
availableTypes: List<CloudLibraryItemType>,
|
||||||
|
onProviderSelected: (String?) -> Unit,
|
||||||
|
onTypeSelected: (CloudLibraryItemType?) -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val providerOptions = buildList {
|
||||||
|
add(NuvioDropdownOption(key = "", label = stringResource(Res.string.cloud_library_provider_all)))
|
||||||
|
addAll(
|
||||||
|
uiState.providers.map { provider ->
|
||||||
|
NuvioDropdownOption(
|
||||||
|
key = provider.providerId,
|
||||||
|
label = provider.providerName,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val typeOptions = buildList {
|
||||||
|
add(NuvioDropdownOption(key = "", label = stringResource(Res.string.cloud_library_type_all)))
|
||||||
|
addAll(
|
||||||
|
availableTypes.map { type ->
|
||||||
|
NuvioDropdownOption(
|
||||||
|
key = type.name,
|
||||||
|
label = cloudLibraryTypeLabel(type),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val selectedProviderName = uiState.providers
|
||||||
|
.firstOrNull { provider -> provider.providerId == selectedProviderId }
|
||||||
|
?.providerName
|
||||||
|
?: stringResource(Res.string.cloud_library_provider_all)
|
||||||
|
val selectedTypeLabel = selectedType?.let { type -> cloudLibraryTypeLabel(type) }
|
||||||
|
?: stringResource(Res.string.cloud_library_type_all)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
NuvioDropdownChip(
|
||||||
|
title = stringResource(Res.string.cloud_library_select_provider),
|
||||||
|
label = selectedProviderName,
|
||||||
|
selectedKey = selectedProviderId.orEmpty(),
|
||||||
|
options = providerOptions,
|
||||||
|
enabled = providerOptions.size > 1,
|
||||||
|
onSelected = { option ->
|
||||||
|
onProviderSelected(option.key.ifBlank { null })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
NuvioDropdownChip(
|
||||||
|
title = stringResource(Res.string.cloud_library_select_type),
|
||||||
|
label = selectedTypeLabel,
|
||||||
|
selectedKey = selectedType?.name.orEmpty(),
|
||||||
|
options = typeOptions,
|
||||||
|
enabled = typeOptions.size > 1,
|
||||||
|
onSelected = { option ->
|
||||||
|
val type = option.key
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?.let(CloudLibraryItemType::valueOf)
|
||||||
|
onTypeSelected(type)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onRefresh) {
|
||||||
else -> {
|
Icon(
|
||||||
librarySections(
|
imageVector = Icons.Rounded.Refresh,
|
||||||
sections = uiState.sections,
|
contentDescription = stringResource(Res.string.cloud_library_refresh),
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
onPosterClick = onPosterClick,
|
|
||||||
onSectionViewAllClick = onSectionViewAllClick,
|
|
||||||
onPosterLongClick = onPosterLongClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryChip(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
loading: Boolean = false,
|
||||||
|
error: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = if (selected) colorScheme.primaryContainer else colorScheme.surfaceContainerLow,
|
||||||
|
border = if (selected) BorderStroke(1.dp, colorScheme.primary.copy(alpha = 0.45f)) else null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
strokeWidth = 1.5.dp,
|
||||||
|
color = colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = when {
|
||||||
|
error -> colorScheme.error
|
||||||
|
selected -> colorScheme.onPrimaryContainer
|
||||||
|
else -> colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CloudLibraryRow(
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val playableCount = item.playableFiles.size
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
.clickable(enabled = playableCount > 0, onClick = onClick),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = item.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = cloudLibrarySubtitle(item),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = cloudLibraryStatusLine(item),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (playableCount > 0) {
|
||||||
|
IconButton(onClick = onClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.PlayArrow,
|
||||||
|
contentDescription = stringResource(Res.string.action_play),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item.progressFraction?.takeIf { it in 0f..0.999f }?.let { progress ->
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CloudLibraryFilePicker(
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onFileSelected: (CloudLibraryFile) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
|
contentDescription = stringResource(Res.string.action_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.cloud_library_file_picker_title),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val files = item.playableFiles
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.cloud_library_no_files_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.cloud_library_no_files_message),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
files.forEach { file ->
|
||||||
|
CloudLibraryFileRow(
|
||||||
|
file = file,
|
||||||
|
onClick = { onFileSelected(file) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CloudLibraryFileRow(
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.58f),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
.size(18.dp),
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
text = file.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = file.sizeBytes?.let { size -> formatCloudBytes(size) }.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.PlayArrow,
|
||||||
|
contentDescription = stringResource(Res.string.cloud_library_play_file),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun cloudLibrarySubtitle(item: CloudLibraryItem): String {
|
||||||
|
val fileLine = when (val playableCount = item.playableFiles.size) {
|
||||||
|
0 -> stringResource(Res.string.cloud_library_no_playable_files)
|
||||||
|
1 -> item.playableFiles.first().name
|
||||||
|
else -> stringResource(Res.string.cloud_library_playable_file_count, playableCount)
|
||||||
|
}
|
||||||
|
return listOf(item.providerName, cloudLibraryTypeLabel(item.type), fileLine).joinToString(" • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun cloudLibraryStatusLine(item: CloudLibraryItem): String {
|
||||||
|
val fallback = if (item.playableFiles.isEmpty()) {
|
||||||
|
stringResource(Res.string.cloud_library_no_playable_files)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.cloud_library_status_ready)
|
||||||
|
}
|
||||||
|
return listOfNotNull(
|
||||||
|
item.status?.toDisplayStatus(),
|
||||||
|
item.sizeBytes?.let(::formatCloudBytes),
|
||||||
|
item.progressFraction?.let { "${(it * 100f).toInt()}%" },
|
||||||
|
).joinToString(" • ").ifBlank { fallback }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun cloudLibraryTypeLabel(type: CloudLibraryItemType): String =
|
||||||
|
when (type) {
|
||||||
|
CloudLibraryItemType.Torrent -> stringResource(Res.string.cloud_library_type_torrents)
|
||||||
|
CloudLibraryItemType.Usenet -> stringResource(Res.string.cloud_library_type_usenet)
|
||||||
|
CloudLibraryItemType.WebDownload -> stringResource(Res.string.cloud_library_type_web)
|
||||||
|
CloudLibraryItemType.File -> stringResource(Res.string.cloud_library_type_files)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatCloudBytes(bytes: Long): String {
|
||||||
|
if (bytes <= 0L) return "0 ${localizedByteUnit("B")}"
|
||||||
|
val kib = 1024.0
|
||||||
|
val mib = kib * 1024.0
|
||||||
|
val gib = mib * 1024.0
|
||||||
|
val value = bytes.toDouble()
|
||||||
|
return when {
|
||||||
|
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}"
|
||||||
|
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}"
|
||||||
|
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}"
|
||||||
|
else -> "$bytes ${localizedByteUnit("B")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toDisplayStatus(): String =
|
||||||
|
replace('_', ' ')
|
||||||
|
.lowercase()
|
||||||
|
.replaceFirstChar { it.titlecase() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CloudLibrarySkeletonToolbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val brush = rememberCloudLibrarySkeletonBrush()
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
CloudSkeletonBlock(brush = brush, width = 112.dp, height = 36.dp, cornerRadius = 12.dp)
|
||||||
|
CloudSkeletonBlock(brush = brush, width = 92.dp, height = 36.dp, cornerRadius = 12.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CloudLibrarySkeletonRow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val brush = rememberCloudLibrarySkeletonBrush()
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
CloudSkeletonBlock(
|
||||||
|
brush = brush,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.74f),
|
||||||
|
height = 18.dp,
|
||||||
|
cornerRadius = 6.dp,
|
||||||
|
)
|
||||||
|
CloudSkeletonBlock(
|
||||||
|
brush = brush,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.9f),
|
||||||
|
height = 14.dp,
|
||||||
|
cornerRadius = 6.dp,
|
||||||
|
)
|
||||||
|
CloudSkeletonBlock(
|
||||||
|
brush = brush,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.52f),
|
||||||
|
height = 12.dp,
|
||||||
|
cornerRadius = 6.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CloudSkeletonBlock(brush = brush, width = 48.dp, height = 48.dp, cornerRadius = 24.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberCloudLibrarySkeletonBrush(): Brush {
|
||||||
|
val shimmerColors = listOf(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f),
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
|
||||||
|
)
|
||||||
|
val transition = rememberInfiniteTransition()
|
||||||
|
val translateAnim by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1000f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Brush.linearGradient(
|
||||||
|
colors = shimmerColors,
|
||||||
|
start = Offset(translateAnim - 200f, 0f),
|
||||||
|
end = Offset(translateAnim, 0f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CloudSkeletonBlock(
|
||||||
|
brush: Brush,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
width: Dp? = null,
|
||||||
|
height: Dp,
|
||||||
|
cornerRadius: Dp,
|
||||||
|
) {
|
||||||
|
val sizeModifier = if (width != null) {
|
||||||
|
modifier.size(width = width, height = height)
|
||||||
|
} else {
|
||||||
|
modifier.height(height)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = sizeModifier
|
||||||
|
.clip(RoundedCornerShape(cornerRadius))
|
||||||
|
.background(brush),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class LibraryViewMode {
|
||||||
|
Saved,
|
||||||
|
Cloud,
|
||||||
|
}
|
||||||
|
|
||||||
private fun LazyListScope.librarySections(
|
private fun LazyListScope.librarySections(
|
||||||
sections: List<LibrarySection>,
|
sections: List<LibrarySection>,
|
||||||
watchedKeys: Set<String>,
|
watchedKeys: Set<String>,
|
||||||
|
showHeaderAccent: Boolean,
|
||||||
onPosterClick: ((LibraryItem) -> Unit)?,
|
onPosterClick: ((LibraryItem) -> Unit)?,
|
||||||
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
|
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
|
||||||
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
|
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
|
||||||
|
|
@ -209,6 +998,7 @@ private fun LazyListScope.librarySections(
|
||||||
entries = previewItems,
|
entries = previewItems,
|
||||||
headerHorizontalPadding = 16.dp,
|
headerHorizontalPadding = 16.dp,
|
||||||
rowContentPadding = PaddingValues(horizontal = 16.dp),
|
rowContentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
showHeaderAccent = showHeaderAccent,
|
||||||
onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) {
|
onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) {
|
||||||
onSectionViewAllClick?.let { { it(section) } }
|
onSectionViewAllClick?.let { { it(section) } }
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,13 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
|
import com.nuvio.app.features.streams.isSelectableForPlayback
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
|
|
@ -460,6 +463,10 @@ private fun EpisodeStreamsSubView(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val debridSettings by remember {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
DebridSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val episode = state.selectedEpisode ?: return
|
val episode = state.selectedEpisode ?: return
|
||||||
val streamsUiState = state.streamsUiState
|
val streamsUiState = state.streamsUiState
|
||||||
|
|
@ -597,10 +604,11 @@ private fun EpisodeStreamsSubView(
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = streams,
|
items = streams,
|
||||||
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
|
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
|
||||||
) { _, stream ->
|
) { _, stream ->
|
||||||
EpisodeSourceStreamRow(
|
EpisodeSourceStreamRow(
|
||||||
stream = stream,
|
stream = stream,
|
||||||
|
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
|
||||||
onClick = { onStreamSelected(stream, episode) },
|
onClick = { onStreamSelected(stream, episode) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -613,6 +621,7 @@ private fun EpisodeStreamsSubView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeSourceStreamRow(
|
private fun EpisodeSourceStreamRow(
|
||||||
stream: StreamItem,
|
stream: StreamItem,
|
||||||
|
enabled: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
@ -622,7 +631,7 @@ private fun EpisodeSourceStreamRow(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
.background(colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
||||||
.clickable(onClick = onClick)
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.nuvio.app.core.ui.NuvioToastController
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
|
||||||
|
import com.nuvio.app.features.debrid.toastMessage
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.AddonResource
|
import com.nuvio.app.features.addons.AddonResource
|
||||||
import com.nuvio.app.features.addons.ManagedAddon
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
|
|
@ -67,6 +72,7 @@ import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||||
import com.nuvio.app.isIos
|
import com.nuvio.app.isIos
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
@ -857,8 +863,56 @@ fun PlayerScreen(
|
||||||
playerController?.seekTo(targetPositionMs)
|
playerController?.seekTo(targetPositionMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun resolveDebridForPlayer(
|
||||||
|
stream: StreamItem,
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
onResolved: (StreamItem) -> Unit,
|
||||||
|
onStale: () -> Unit,
|
||||||
|
): Boolean {
|
||||||
|
if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false
|
||||||
|
scope.launch {
|
||||||
|
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
|
||||||
|
stream = stream,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
)
|
||||||
|
when (resolved) {
|
||||||
|
is DirectDebridPlayableResult.Success -> onResolved(resolved.stream)
|
||||||
|
else -> {
|
||||||
|
resolved.toastMessage()?.let { NuvioToastController.show(it) }
|
||||||
|
if (resolved == DirectDebridPlayableResult.Stale) {
|
||||||
|
onStale()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun switchToSource(stream: StreamItem) {
|
fun switchToSource(stream: StreamItem) {
|
||||||
val url = stream.directPlaybackUrl ?: return
|
if (
|
||||||
|
resolveDebridForPlayer(
|
||||||
|
stream = stream,
|
||||||
|
season = activeSeasonNumber,
|
||||||
|
episode = activeEpisodeNumber,
|
||||||
|
onResolved = ::switchToSource,
|
||||||
|
onStale = {
|
||||||
|
val type = contentType ?: parentMetaType
|
||||||
|
val vid = activeVideoId
|
||||||
|
if (vid != null) {
|
||||||
|
PlayerStreamsRepository.loadSources(
|
||||||
|
type = type,
|
||||||
|
videoId = vid,
|
||||||
|
season = activeSeasonNumber,
|
||||||
|
episode = activeEpisodeNumber,
|
||||||
|
forceRefresh = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
) return
|
||||||
|
val url = stream.playableDirectUrl ?: return
|
||||||
if (url == activeSourceUrl) return
|
if (url == activeSourceUrl) return
|
||||||
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
|
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
|
||||||
flushWatchProgress()
|
flushWatchProgress()
|
||||||
|
|
@ -899,7 +953,27 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
|
fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
|
||||||
val url = stream.directPlaybackUrl ?: return
|
if (
|
||||||
|
resolveDebridForPlayer(
|
||||||
|
stream = stream,
|
||||||
|
season = episode.season,
|
||||||
|
episode = episode.episode,
|
||||||
|
onResolved = { resolvedStream ->
|
||||||
|
switchToEpisodeStream(resolvedStream, episode)
|
||||||
|
},
|
||||||
|
onStale = {
|
||||||
|
val type = contentType ?: parentMetaType
|
||||||
|
PlayerStreamsRepository.loadEpisodeStreams(
|
||||||
|
type = type,
|
||||||
|
videoId = episode.id,
|
||||||
|
season = episode.season,
|
||||||
|
episode = episode.episode,
|
||||||
|
forceRefresh = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
) return
|
||||||
|
val url = stream.playableDirectUrl ?: return
|
||||||
showNextEpisodeCard = false
|
showNextEpisodeCard = false
|
||||||
showSourcesPanel = false
|
showSourcesPanel = false
|
||||||
showEpisodesPanel = false
|
showEpisodesPanel = false
|
||||||
|
|
@ -1094,12 +1168,30 @@ fun PlayerScreen(
|
||||||
val installedAddonNames = AddonRepository.uiState.value.addons
|
val installedAddonNames = AddonRepository.uiState.value.addons
|
||||||
.map { it.displayTitle }
|
.map { it.displayTitle }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
val debridSettings = DebridSettingsRepository.snapshot()
|
||||||
|
|
||||||
val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds
|
val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds
|
||||||
val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE
|
|
||||||
var autoSelectTriggered = false
|
var autoSelectTriggered = false
|
||||||
var timeoutElapsed = false
|
var timeoutElapsed = false
|
||||||
var selectedStream: StreamItem? = null
|
var selectedStream: StreamItem? = null
|
||||||
|
val autoSelectSettled = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
fun settleAutoSelect() {
|
||||||
|
if (!autoSelectSettled.isCompleted) {
|
||||||
|
autoSelectSettled.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectStream(stream: StreamItem) {
|
||||||
|
autoSelectTriggered = true
|
||||||
|
selectedStream = stream
|
||||||
|
settleAutoSelect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishWithoutSelection() {
|
||||||
|
autoSelectTriggered = true
|
||||||
|
settleAutoSelect()
|
||||||
|
}
|
||||||
|
|
||||||
// Full select: tries binge group first, then falls back to mode-based selection
|
// Full select: tries binge group first, then falls back to mode-based selection
|
||||||
fun trySelectStream(streams: List<StreamItem>): StreamItem? {
|
fun trySelectStream(streams: List<StreamItem>): StreamItem? {
|
||||||
|
|
@ -1114,6 +1206,8 @@ fun PlayerScreen(
|
||||||
preferredBingeGroup = preferredBingeGroup,
|
preferredBingeGroup = preferredBingeGroup,
|
||||||
preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup,
|
preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup,
|
||||||
bingeGroupOnly = bingeGroupOnlyManualMode,
|
bingeGroupOnly = bingeGroupOnlyManualMode,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
activeResolverProviderId = debridSettings.activeResolverProviderId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1131,6 +1225,8 @@ fun PlayerScreen(
|
||||||
preferredBingeGroup = preferredBingeGroup,
|
preferredBingeGroup = preferredBingeGroup,
|
||||||
preferBingeGroupInSelection = true,
|
preferBingeGroupInSelection = true,
|
||||||
bingeGroupOnly = true,
|
bingeGroupOnly = true,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
activeResolverProviderId = debridSettings.activeResolverProviderId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1145,38 +1241,35 @@ fun PlayerScreen(
|
||||||
// Already resolved
|
// Already resolved
|
||||||
} else if (timeoutElapsed) {
|
} else if (timeoutElapsed) {
|
||||||
// Timeout elapsed: full select (binge group + fallback to mode)
|
// Timeout elapsed: full select (binge group + fallback to mode)
|
||||||
if (allStreams.isNotEmpty()) {
|
if (allStreams.isNotEmpty()) {
|
||||||
val candidate = trySelectStream(allStreams)
|
val candidate = trySelectStream(allStreams)
|
||||||
if (candidate != null) {
|
if (candidate != null) {
|
||||||
autoSelectTriggered = true
|
selectStream(candidate)
|
||||||
selectedStream = candidate
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
// Before timeout: eagerly check binge group only
|
||||||
// Before timeout: eagerly check binge group only
|
if (allStreams.isNotEmpty()) {
|
||||||
if (allStreams.isNotEmpty()) {
|
val earlyMatch = tryBingeGroupOnly(allStreams)
|
||||||
val earlyMatch = tryBingeGroupOnly(allStreams)
|
if (earlyMatch != null) {
|
||||||
if (earlyMatch != null) {
|
selectStream(earlyMatch)
|
||||||
autoSelectTriggered = true
|
}
|
||||||
selectedStream = earlyMatch
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all addons finished loading and no match yet, do a final full select
|
// If all addons finished loading and no match yet, do a final full select
|
||||||
if (!autoSelectTriggered && !state.isAnyLoading) {
|
if (!autoSelectTriggered && !state.isAnyLoading) {
|
||||||
if (allStreams.isNotEmpty()) {
|
if (allStreams.isNotEmpty()) {
|
||||||
val candidate = trySelectStream(allStreams)
|
val candidate = trySelectStream(allStreams)
|
||||||
if (candidate != null) {
|
if (candidate != null) {
|
||||||
autoSelectTriggered = true
|
selectStream(candidate)
|
||||||
selectedStream = candidate
|
}
|
||||||
}
|
}
|
||||||
}
|
if (!autoSelectTriggered) {
|
||||||
if (!autoSelectTriggered) {
|
finishWithoutSelection()
|
||||||
autoSelectTriggered = true
|
}
|
||||||
}
|
return@collectLatest
|
||||||
return@collectLatest
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (autoSelectTriggered) return@collectLatest
|
if (autoSelectTriggered) return@collectLatest
|
||||||
}
|
}
|
||||||
|
|
@ -1192,51 +1285,56 @@ fun PlayerScreen(
|
||||||
timeoutElapsed = true
|
timeoutElapsed = true
|
||||||
if (!autoSelectTriggered) {
|
if (!autoSelectTriggered) {
|
||||||
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
|
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
|
||||||
if (allStreams.isNotEmpty()) {
|
if (allStreams.isNotEmpty()) {
|
||||||
val candidate = trySelectStream(allStreams)
|
val candidate = trySelectStream(allStreams)
|
||||||
if (candidate != null) {
|
if (candidate != null) {
|
||||||
autoSelectTriggered = true
|
selectStream(candidate)
|
||||||
selectedStream = candidate
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (selectedStream != null) {
|
||||||
if (selectedStream != null) {
|
innerJob.cancel()
|
||||||
innerJob.cancel()
|
} else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) {
|
||||||
} else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) {
|
// Streams arrived but no match after full select — don't wait further
|
||||||
// Streams arrived but no match after full select — don't wait further
|
innerJob.cancel()
|
||||||
innerJob.cancel()
|
finishWithoutSelection()
|
||||||
autoSelectTriggered = true
|
} else {
|
||||||
} else {
|
// No addon responded yet — wait with hard ceiling
|
||||||
// No addon responded yet — wait with hard ceiling
|
val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() }
|
||||||
val completed = withTimeoutOrNull(timeoutMs) { innerJob.join() }
|
innerJob.cancel()
|
||||||
if (completed == null) {
|
if (completed == null) {
|
||||||
innerJob.cancel()
|
if (!autoSelectTriggered) {
|
||||||
if (!autoSelectTriggered) {
|
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
|
||||||
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
|
if (allStreams.isNotEmpty()) {
|
||||||
if (allStreams.isNotEmpty()) {
|
selectedStream = trySelectStream(allStreams)
|
||||||
selectedStream = trySelectStream(allStreams)
|
}
|
||||||
}
|
finishWithoutSelection()
|
||||||
autoSelectTriggered = true
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
// Instant (0) or unlimited: timeoutElapsed immediately so each
|
||||||
// Instant (0) or unlimited: timeoutElapsed immediately so each
|
// addon response triggers a full select attempt in the collect.
|
||||||
// addon response triggers a full select attempt in the collect.
|
timeoutElapsed = true
|
||||||
timeoutElapsed = true
|
if (!autoSelectTriggered) {
|
||||||
val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS
|
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
|
||||||
val completed = withTimeoutOrNull(hardTimeout) { innerJob.join() }
|
if (allStreams.isNotEmpty()) {
|
||||||
if (completed == null) {
|
trySelectStream(allStreams)?.let(::selectStream)
|
||||||
innerJob.cancel()
|
}
|
||||||
if (!autoSelectTriggered) {
|
}
|
||||||
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
|
val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS
|
||||||
if (allStreams.isNotEmpty()) {
|
val completed = withTimeoutOrNull(hardTimeout) { autoSelectSettled.await() }
|
||||||
selectedStream = trySelectStream(allStreams)
|
innerJob.cancel()
|
||||||
}
|
if (completed == null) {
|
||||||
autoSelectTriggered = true
|
if (!autoSelectTriggered) {
|
||||||
}
|
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
|
||||||
}
|
if (allStreams.isNotEmpty()) {
|
||||||
}
|
selectedStream = trySelectStream(allStreams)
|
||||||
|
}
|
||||||
|
finishWithoutSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle result
|
// Handle result
|
||||||
nextEpisodeAutoPlaySearching = false
|
nextEpisodeAutoPlaySearching = false
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -47,9 +48,12 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.i18n.localizedByteUnit
|
import com.nuvio.app.core.i18n.localizedByteUnit
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
|
import com.nuvio.app.features.streams.isSelectableForPlayback
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
@ -67,6 +71,10 @@ fun PlayerSourcesPanel(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val debridSettings by remember {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
DebridSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
|
|
@ -203,7 +211,7 @@ fun PlayerSourcesPanel(
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = streams,
|
items = streams,
|
||||||
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
|
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
|
||||||
) { _, stream ->
|
) { _, stream ->
|
||||||
val isCurrent = isCurrentStream(
|
val isCurrent = isCurrentStream(
|
||||||
stream = stream,
|
stream = stream,
|
||||||
|
|
@ -213,6 +221,7 @@ fun PlayerSourcesPanel(
|
||||||
SourceStreamRow(
|
SourceStreamRow(
|
||||||
stream = stream,
|
stream = stream,
|
||||||
isCurrent = isCurrent,
|
isCurrent = isCurrent,
|
||||||
|
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
|
||||||
onClick = { onStreamSelected(stream) },
|
onClick = { onStreamSelected(stream) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +239,7 @@ fun PlayerSourcesPanel(
|
||||||
private fun SourceStreamRow(
|
private fun SourceStreamRow(
|
||||||
stream: StreamItem,
|
stream: StreamItem,
|
||||||
isCurrent: Boolean,
|
isCurrent: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
@ -256,7 +266,7 @@ private fun SourceStreamRow(
|
||||||
Modifier
|
Modifier
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.clickable(onClick = onClick)
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
.padding(14.dp),
|
.padding(14.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
|
@ -452,9 +462,9 @@ private fun isCurrentStream(
|
||||||
currentUrl: String?,
|
currentUrl: String?,
|
||||||
currentName: String?,
|
currentName: String?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (currentUrl != null && stream.directPlaybackUrl == currentUrl) return true
|
if (currentUrl != null && stream.playableDirectUrl == currentUrl) return true
|
||||||
if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) &&
|
if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) &&
|
||||||
stream.directPlaybackUrl == currentUrl
|
stream.playableDirectUrl == currentUrl
|
||||||
) return true
|
) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,16 @@ import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
|
import com.nuvio.app.features.debrid.DebridStreamPresentation
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
|
||||||
|
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.plugins.PluginRepository
|
import com.nuvio.app.features.plugins.PluginRepository
|
||||||
import com.nuvio.app.features.plugins.pluginContentId
|
import com.nuvio.app.features.plugins.pluginContentId
|
||||||
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
||||||
import com.nuvio.app.features.plugins.PluginScraper
|
import com.nuvio.app.features.plugins.PluginScraper
|
||||||
|
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
|
||||||
import com.nuvio.app.features.streams.AddonStreamGroup
|
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||||
import com.nuvio.app.features.streams.StreamAutoPlaySelector
|
import com.nuvio.app.features.streams.StreamAutoPlaySelector
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
|
@ -154,6 +159,10 @@ object PlayerStreamsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
val installedAddons = AddonRepository.uiState.value.addons
|
val installedAddons = AddonRepository.uiState.value.addons
|
||||||
|
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
|
||||||
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
|
val playerSettings = PlayerSettingsRepository.uiState.value
|
||||||
|
val debridSettings = DebridSettingsRepository.snapshot()
|
||||||
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
|
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
|
||||||
PluginRepository.initialize()
|
PluginRepository.initialize()
|
||||||
PluginRepository.getEnabledScrapersForType(type)
|
PluginRepository.getEnabledScrapersForType(type)
|
||||||
|
|
@ -196,8 +205,13 @@ object PlayerStreamsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
val installedAddonOrder = streamAddons.map { it.addonName }
|
val installedAddonOrder = streamAddons.map { it.addonName }
|
||||||
|
val warmedAddonGroups = AddonStreamWarmupRepository
|
||||||
|
.cachedGroups(type = type, videoId = videoId, season = season, episode = episode)
|
||||||
|
.orEmpty()
|
||||||
|
.associateBy { it.addonId }
|
||||||
|
val warmedAddonIds = warmedAddonGroups.keys
|
||||||
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
|
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
|
||||||
AddonStreamGroup(
|
warmedAddonGroups[addon.addonId] ?: AddonStreamGroup(
|
||||||
addonName = addon.addonName,
|
addonName = addon.addonName,
|
||||||
addonId = addon.addonId,
|
addonId = addon.addonId,
|
||||||
streams = emptyList(),
|
streams = emptyList(),
|
||||||
|
|
@ -211,14 +225,72 @@ object PlayerStreamsRepository {
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
)
|
)
|
||||||
}, installedAddonOrder)
|
}, installedAddonOrder)
|
||||||
|
val isInitiallyLoading = initialGroups.any { it.isLoading }
|
||||||
stateFlow.value = StreamsUiState(
|
stateFlow.value = StreamsUiState(
|
||||||
groups = initialGroups,
|
groups = initialGroups,
|
||||||
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
||||||
isAnyLoading = true,
|
isAnyLoading = isInitiallyLoading,
|
||||||
)
|
)
|
||||||
|
|
||||||
val job = scope.launch {
|
val job = scope.launch {
|
||||||
val addonJobs = streamAddons.map { addon ->
|
val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds }
|
||||||
|
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
|
||||||
|
val debridAvailabilityJobs = mutableListOf<Job>()
|
||||||
|
fun emptyStateReason(groups: List<AddonStreamGroup>, anyLoading: Boolean) =
|
||||||
|
if (!anyLoading && groups.all { it.streams.isEmpty() }) {
|
||||||
|
if (groups.all { !it.error.isNullOrBlank() }) {
|
||||||
|
com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed
|
||||||
|
} else {
|
||||||
|
com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun presentDebridGroup(group: AddonStreamGroup): AddonStreamGroup =
|
||||||
|
DebridStreamPresentation.apply(
|
||||||
|
groups = listOf(group),
|
||||||
|
settings = debridSettings,
|
||||||
|
).firstOrNull() ?: group
|
||||||
|
|
||||||
|
fun publishStreamGroup(group: AddonStreamGroup) {
|
||||||
|
stateFlow.update { current ->
|
||||||
|
val updated = StreamAutoPlaySelector.orderAddonStreams(
|
||||||
|
groups = current.groups.map { currentGroup ->
|
||||||
|
if (currentGroup.addonId == group.addonId) group else currentGroup
|
||||||
|
},
|
||||||
|
installedOrder = installedAddonOrder,
|
||||||
|
)
|
||||||
|
val anyLoading = updated.any { it.isLoading }
|
||||||
|
current.copy(
|
||||||
|
groups = updated,
|
||||||
|
isAnyLoading = anyLoading,
|
||||||
|
emptyStateReason = emptyStateReason(updated, anyLoading),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchDebridAvailability(group: AddonStreamGroup) {
|
||||||
|
if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return
|
||||||
|
|
||||||
|
val eligibleGroupIds = setOf(group.addonId)
|
||||||
|
val checkingGroup = LocalDebridAvailabilityService.markChecking(
|
||||||
|
groups = listOf(group),
|
||||||
|
eligibleGroupIds = eligibleGroupIds,
|
||||||
|
).firstOrNull() ?: group
|
||||||
|
publishStreamGroup(checkingGroup)
|
||||||
|
|
||||||
|
val availabilityJob = launch {
|
||||||
|
val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability(
|
||||||
|
groups = listOf(checkingGroup),
|
||||||
|
eligibleGroupIds = eligibleGroupIds,
|
||||||
|
).firstOrNull() ?: checkingGroup
|
||||||
|
publishStreamGroup(presentDebridGroup(availabilityGroup))
|
||||||
|
}
|
||||||
|
debridAvailabilityJobs += availabilityJob
|
||||||
|
}
|
||||||
|
|
||||||
|
val addonJobs = pendingStreamAddons.map { addon ->
|
||||||
async {
|
async {
|
||||||
val url = buildAddonResourceUrl(
|
val url = buildAddonResourceUrl(
|
||||||
manifestUrl = addon.manifest.transportUrl,
|
manifestUrl = addon.manifest.transportUrl,
|
||||||
|
|
@ -287,23 +359,32 @@ object PlayerStreamsRepository {
|
||||||
}
|
}
|
||||||
repeat(jobs.size) {
|
repeat(jobs.size) {
|
||||||
val result = completions.receive()
|
val result = completions.receive()
|
||||||
stateFlow.update { current ->
|
publishStreamGroup(result)
|
||||||
val updated = StreamAutoPlaySelector.orderAddonStreams(
|
launchDebridAvailability(result)
|
||||||
groups = current.groups.map { g -> if (g.addonId == result.addonId) result else g },
|
}
|
||||||
installedOrder = installedAddonOrder,
|
for (availabilityJob in debridAvailabilityJobs) {
|
||||||
)
|
availabilityJob.join()
|
||||||
val anyLoading = updated.any { it.isLoading }
|
}
|
||||||
current.copy(
|
launch {
|
||||||
groups = updated,
|
DirectDebridStreamPreparer.prepare(
|
||||||
isAnyLoading = anyLoading,
|
streams = stateFlow.value.groups
|
||||||
emptyStateReason = if (!anyLoading && updated.all { it.streams.isEmpty() }) {
|
.filter { it.addonId in installedAddonIds }
|
||||||
if (updated.all { !it.error.isNullOrBlank() }) {
|
.flatMap { it.streams },
|
||||||
com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed
|
season = season,
|
||||||
} else {
|
episode = episode,
|
||||||
com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound
|
playerSettings = playerSettings,
|
||||||
}
|
installedAddonNames = installedAddonNames,
|
||||||
} else null,
|
) { original, prepared ->
|
||||||
)
|
stateFlow.update { current ->
|
||||||
|
current.copy(
|
||||||
|
groups = DirectDebridStreamPreparer.replacePreparedStream(
|
||||||
|
groups = current.groups,
|
||||||
|
original = original,
|
||||||
|
prepared = prepared,
|
||||||
|
eligibleGroupIds = installedAddonIds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
completions.close()
|
completions.close()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package com.nuvio.app.features.search
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -13,31 +12,16 @@ import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Check
|
|
||||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SheetState
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -49,12 +33,9 @@ import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.core.network.NetworkCondition
|
import com.nuvio.app.core.network.NetworkCondition
|
||||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||||
|
import com.nuvio.app.core.ui.NuvioDropdownChip
|
||||||
|
import com.nuvio.app.core.ui.NuvioDropdownOption
|
||||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
|
|
||||||
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
|
|
||||||
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
|
||||||
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
|
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
|
||||||
import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
|
import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
import com.nuvio.app.core.ui.posterCardClickable
|
import com.nuvio.app.core.ui.posterCardClickable
|
||||||
|
|
@ -62,7 +43,6 @@ import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
|
@ -174,19 +154,19 @@ private fun DiscoverFilterRow(
|
||||||
modifier = modifier.horizontalScroll(rememberScrollState()),
|
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
DiscoverDropdownChip(
|
NuvioDropdownChip(
|
||||||
title = stringResource(Res.string.discover_select_type),
|
title = stringResource(Res.string.discover_select_type),
|
||||||
label = state.selectedType?.displayTypeLabel() ?: stringResource(Res.string.discover_type),
|
label = state.selectedType?.displayTypeLabel() ?: stringResource(Res.string.discover_type),
|
||||||
selectedKey = state.selectedType,
|
selectedKey = state.selectedType,
|
||||||
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
|
options = state.typeOptions.map { NuvioDropdownOption(key = it, label = it.displayTypeLabel()) },
|
||||||
enabled = state.typeOptions.isNotEmpty(),
|
enabled = state.typeOptions.isNotEmpty(),
|
||||||
onSelected = { onTypeSelected(it.key) },
|
onSelected = { onTypeSelected(it.key) },
|
||||||
)
|
)
|
||||||
DiscoverDropdownChip(
|
NuvioDropdownChip(
|
||||||
title = stringResource(Res.string.discover_select_catalog),
|
title = stringResource(Res.string.discover_select_catalog),
|
||||||
label = state.selectedCatalog?.catalogName ?: stringResource(Res.string.discover_catalog),
|
label = state.selectedCatalog?.catalogName ?: stringResource(Res.string.discover_catalog),
|
||||||
selectedKey = state.selectedCatalogKey,
|
selectedKey = state.selectedCatalogKey,
|
||||||
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
|
options = state.catalogOptions.map { option -> NuvioDropdownOption(key = option.key, label = option.catalogName) },
|
||||||
enabled = state.catalogOptions.isNotEmpty(),
|
enabled = state.catalogOptions.isNotEmpty(),
|
||||||
onSelected = { onCatalogSelected(it.key) },
|
onSelected = { onCatalogSelected(it.key) },
|
||||||
)
|
)
|
||||||
|
|
@ -194,11 +174,11 @@ private fun DiscoverFilterRow(
|
||||||
val selectedCatalog = state.selectedCatalog
|
val selectedCatalog = state.selectedCatalog
|
||||||
val genreOptions = buildList {
|
val genreOptions = buildList {
|
||||||
if (selectedCatalog?.genreRequired != true) {
|
if (selectedCatalog?.genreRequired != true) {
|
||||||
add(DiscoverOptionItem(key = "", label = stringResource(Res.string.discover_all_genres)))
|
add(NuvioDropdownOption(key = "", label = stringResource(Res.string.discover_all_genres)))
|
||||||
}
|
}
|
||||||
addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
|
addAll(state.genreOptions.map { genre -> NuvioDropdownOption(key = genre, label = genre) })
|
||||||
}
|
}
|
||||||
DiscoverDropdownChip(
|
NuvioDropdownChip(
|
||||||
title = stringResource(Res.string.discover_select_genre),
|
title = stringResource(Res.string.discover_select_genre),
|
||||||
label = state.selectedGenre ?: stringResource(Res.string.discover_all_genres),
|
label = state.selectedGenre ?: stringResource(Res.string.discover_all_genres),
|
||||||
selectedKey = state.selectedGenre ?: "",
|
selectedKey = state.selectedGenre ?: "",
|
||||||
|
|
@ -211,132 +191,6 @@ private fun DiscoverFilterRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun DiscoverDropdownChip(
|
|
||||||
title: String,
|
|
||||||
label: String,
|
|
||||||
selectedKey: String?,
|
|
||||||
options: List<DiscoverOptionItem>,
|
|
||||||
enabled: Boolean,
|
|
||||||
onSelected: (DiscoverOptionItem) -> Unit,
|
|
||||||
) {
|
|
||||||
var isSheetVisible by remember { mutableStateOf(false) }
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.then(
|
|
||||||
if (enabled) {
|
|
||||||
Modifier.clickable { isSheetVisible = true }
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.KeyboardArrowDown,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSheetVisible) {
|
|
||||||
DiscoverOptionsSheet(
|
|
||||||
title = title,
|
|
||||||
options = options,
|
|
||||||
selectedKey = selectedKey,
|
|
||||||
sheetState = sheetState,
|
|
||||||
onDismiss = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
dismissNuvioBottomSheet(
|
|
||||||
sheetState = sheetState,
|
|
||||||
onDismiss = { isSheetVisible = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSelected = { option ->
|
|
||||||
onSelected(option)
|
|
||||||
coroutineScope.launch {
|
|
||||||
dismissNuvioBottomSheet(
|
|
||||||
sheetState = sheetState,
|
|
||||||
onDismiss = { isSheetVisible = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun DiscoverOptionsSheet(
|
|
||||||
title: String,
|
|
||||||
options: List<DiscoverOptionItem>,
|
|
||||||
selectedKey: String?,
|
|
||||||
sheetState: SheetState,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onSelected: (DiscoverOptionItem) -> Unit,
|
|
||||||
) {
|
|
||||||
NuvioModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = sheetState,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
NuvioBottomSheetDivider()
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(max = 420.dp),
|
|
||||||
) {
|
|
||||||
itemsIndexed(options) { index, option ->
|
|
||||||
NuvioBottomSheetActionRow(
|
|
||||||
title = option.label,
|
|
||||||
onClick = { onSelected(option) },
|
|
||||||
trailingContent = {
|
|
||||||
if (option.key == selectedKey) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (index < options.lastIndex) {
|
|
||||||
NuvioBottomSheetDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscoverGridRow(
|
private fun DiscoverGridRow(
|
||||||
items: List<MetaPreview>,
|
items: List<MetaPreview>,
|
||||||
|
|
@ -518,11 +372,6 @@ private fun DiscoverEmptyStateCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class DiscoverOptionItem(
|
|
||||||
val key: String,
|
|
||||||
val label: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun String.displayTypeLabel(): String =
|
private fun String.displayTypeLabel(): String =
|
||||||
when (lowercase()) {
|
when (lowercase()) {
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,10 @@ fun SearchScreen(
|
||||||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
|
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
|
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
|
||||||
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
val homeCatalogSettingsUiState by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
|
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
@ -305,13 +308,19 @@ fun SearchScreen(
|
||||||
when {
|
when {
|
||||||
isWaitingForSearch -> {
|
isWaitingForSearch -> {
|
||||||
items(2) {
|
items(2) {
|
||||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
HomeSkeletonRow(
|
||||||
|
modifier = Modifier.padding(horizontal = homeSectionPadding),
|
||||||
|
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uiState.isLoading && uiState.sections.isEmpty() -> {
|
uiState.isLoading && uiState.sections.isEmpty() -> {
|
||||||
items(2) {
|
items(2) {
|
||||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
HomeSkeletonRow(
|
||||||
|
modifier = Modifier.padding(horizontal = homeSectionPadding),
|
||||||
|
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,7 +360,10 @@ fun SearchScreen(
|
||||||
}
|
}
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
item(key = "search_loading_more") {
|
item(key = "search_loading_more") {
|
||||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
HomeSkeletonRow(
|
||||||
|
modifier = Modifier.padding(horizontal = homeSectionPadding),
|
||||||
|
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,14 @@
|
||||||
package com.nuvio.app.features.settings
|
package com.nuvio.app.features.settings
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.CloudQueue
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
|
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment
|
import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment
|
||||||
import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description
|
import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_integrations_debrid_description
|
||||||
import nuvio.composeapp.generated.resources.settings_integrations_section_title
|
import nuvio.composeapp.generated.resources.settings_integrations_section_title
|
||||||
import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description
|
import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
@ -13,6 +17,7 @@ internal fun LazyListScope.integrationsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
onTmdbClick: () -> Unit,
|
onTmdbClick: () -> Unit,
|
||||||
onMdbListClick: () -> Unit,
|
onMdbListClick: () -> Unit,
|
||||||
|
onDebridClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
|
|
@ -35,6 +40,14 @@ internal fun LazyListScope.integrationsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onMdbListClick,
|
onClick = onMdbListClick,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = stringResource(Res.string.compose_settings_page_debrid),
|
||||||
|
description = stringResource(Res.string.settings_integrations_debrid_description),
|
||||||
|
icon = Icons.Rounded.CloudQueue,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onClick = onDebridClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_account
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_addons
|
import nuvio.composeapp.generated.resources.compose_settings_page_addons
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
|
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
|
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
|
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
|
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
|
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
|
||||||
|
|
@ -129,6 +130,11 @@ internal enum class SettingsPage(
|
||||||
category = SettingsCategory.General,
|
category = SettingsCategory.General,
|
||||||
parentPage = Integrations,
|
parentPage = Integrations,
|
||||||
),
|
),
|
||||||
|
Debrid(
|
||||||
|
titleRes = Res.string.compose_settings_page_debrid,
|
||||||
|
category = SettingsCategory.General,
|
||||||
|
parentPage = Integrations,
|
||||||
|
),
|
||||||
TraktAuthentication(
|
TraktAuthentication(
|
||||||
titleRes = Res.string.compose_settings_page_trakt,
|
titleRes = Res.string.compose_settings_page_trakt,
|
||||||
category = SettingsCategory.Account,
|
category = SettingsCategory.Account,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleUiState
|
import com.nuvio.app.core.ui.PosterCardStyleUiState
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettings
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
import com.nuvio.app.features.home.HomeCatalogSettingsItem
|
import com.nuvio.app.features.home.HomeCatalogSettingsItem
|
||||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.mdblist.MdbListSettings
|
import com.nuvio.app.features.mdblist.MdbListSettings
|
||||||
|
|
@ -92,6 +94,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
rootActionRequests: Flow<Unit> = emptyFlow(),
|
rootActionRequests: Flow<Unit> = emptyFlow(),
|
||||||
|
requestedPageName: String? = null,
|
||||||
|
onRequestedPageConsumed: () -> Unit = {},
|
||||||
rootActionsEnabled: Boolean = true,
|
rootActionsEnabled: Boolean = true,
|
||||||
onSwitchProfile: (() -> Unit)? = null,
|
onSwitchProfile: (() -> Unit)? = null,
|
||||||
onHomescreenClick: () -> Unit = {},
|
onHomescreenClick: () -> Unit = {},
|
||||||
|
|
@ -132,6 +136,10 @@ fun SettingsScreen(
|
||||||
MdbListSettingsRepository.ensureLoaded()
|
MdbListSettingsRepository.ensureLoaded()
|
||||||
MdbListSettingsRepository.uiState
|
MdbListSettingsRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val debridSettings by remember {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
DebridSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val traktAuthUiState by remember {
|
val traktAuthUiState by remember {
|
||||||
TraktAuthRepository.ensureLoaded()
|
TraktAuthRepository.ensureLoaded()
|
||||||
TraktAuthRepository.uiState
|
TraktAuthRepository.uiState
|
||||||
|
|
@ -215,6 +223,15 @@ fun SettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(requestedPageName, rootActionsEnabled) {
|
||||||
|
val targetPage = requestedPageName
|
||||||
|
?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() }
|
||||||
|
?: return@LaunchedEffect
|
||||||
|
if (!rootActionsEnabled) return@LaunchedEffect
|
||||||
|
currentPage = targetPage.name
|
||||||
|
onRequestedPageConsumed()
|
||||||
|
}
|
||||||
|
|
||||||
PlatformBackHandler(
|
PlatformBackHandler(
|
||||||
enabled = rootActionsEnabled && previousPage != null,
|
enabled = rootActionsEnabled && previousPage != null,
|
||||||
onBack = { previousPage?.let { currentPage = it.name } },
|
onBack = { previousPage?.let { currentPage = it.name } },
|
||||||
|
|
@ -251,6 +268,7 @@ fun SettingsScreen(
|
||||||
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
||||||
tmdbSettings = tmdbSettings,
|
tmdbSettings = tmdbSettings,
|
||||||
mdbListSettings = mdbListSettings,
|
mdbListSettings = mdbListSettings,
|
||||||
|
debridSettings = debridSettings,
|
||||||
traktAuthUiState = traktAuthUiState,
|
traktAuthUiState = traktAuthUiState,
|
||||||
traktCommentsEnabled = traktCommentsEnabled,
|
traktCommentsEnabled = traktCommentsEnabled,
|
||||||
traktSettingsUiState = traktSettingsUiState,
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
|
|
@ -299,6 +317,7 @@ fun SettingsScreen(
|
||||||
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
||||||
tmdbSettings = tmdbSettings,
|
tmdbSettings = tmdbSettings,
|
||||||
mdbListSettings = mdbListSettings,
|
mdbListSettings = mdbListSettings,
|
||||||
|
debridSettings = debridSettings,
|
||||||
traktAuthUiState = traktAuthUiState,
|
traktAuthUiState = traktAuthUiState,
|
||||||
traktCommentsEnabled = traktCommentsEnabled,
|
traktCommentsEnabled = traktCommentsEnabled,
|
||||||
traktSettingsUiState = traktSettingsUiState,
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
|
|
@ -357,6 +376,7 @@ private fun MobileSettingsScreen(
|
||||||
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
||||||
tmdbSettings: TmdbSettings,
|
tmdbSettings: TmdbSettings,
|
||||||
mdbListSettings: MdbListSettings,
|
mdbListSettings: MdbListSettings,
|
||||||
|
debridSettings: DebridSettings,
|
||||||
traktAuthUiState: TraktAuthUiState,
|
traktAuthUiState: TraktAuthUiState,
|
||||||
traktCommentsEnabled: Boolean,
|
traktCommentsEnabled: Boolean,
|
||||||
traktSettingsUiState: TraktSettingsUiState,
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
|
|
@ -571,6 +591,7 @@ private fun MobileSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
||||||
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
|
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
|
||||||
|
onDebridClick = { onPageChange(SettingsPage.Debrid) },
|
||||||
)
|
)
|
||||||
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
|
|
@ -580,6 +601,10 @@ private fun MobileSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
settings = mdbListSettings,
|
settings = mdbListSettings,
|
||||||
)
|
)
|
||||||
|
SettingsPage.Debrid -> debridSettingsContent(
|
||||||
|
isTablet = false,
|
||||||
|
settings = debridSettings,
|
||||||
|
)
|
||||||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
uiState = traktAuthUiState,
|
uiState = traktAuthUiState,
|
||||||
|
|
@ -665,6 +690,7 @@ private fun TabletSettingsScreen(
|
||||||
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
||||||
tmdbSettings: TmdbSettings,
|
tmdbSettings: TmdbSettings,
|
||||||
mdbListSettings: MdbListSettings,
|
mdbListSettings: MdbListSettings,
|
||||||
|
debridSettings: DebridSettings,
|
||||||
traktAuthUiState: TraktAuthUiState,
|
traktAuthUiState: TraktAuthUiState,
|
||||||
traktCommentsEnabled: Boolean,
|
traktCommentsEnabled: Boolean,
|
||||||
traktSettingsUiState: TraktSettingsUiState,
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
|
|
@ -937,6 +963,7 @@ private fun TabletSettingsScreen(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
||||||
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
|
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
|
||||||
|
onDebridClick = { onPageChange(SettingsPage.Debrid) },
|
||||||
)
|
)
|
||||||
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
|
|
@ -946,6 +973,10 @@ private fun TabletSettingsScreen(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
settings = mdbListSettings,
|
settings = mdbListSettings,
|
||||||
)
|
)
|
||||||
|
SettingsPage.Debrid -> debridSettingsContent(
|
||||||
|
isTablet = true,
|
||||||
|
settings = debridSettings,
|
||||||
|
)
|
||||||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
uiState = traktAuthUiState,
|
uiState = traktAuthUiState,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
package com.nuvio.app.features.streams
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.features.addons.AddonManifest
|
||||||
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettings
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
|
import com.nuvio.app.features.debrid.DebridStreamPresentation
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
|
||||||
|
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
|
||||||
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
private const val ADDON_STREAM_WARMUP_CACHE_TTL_MS = 5L * 60L * 1000L
|
||||||
|
|
||||||
|
object AddonStreamWarmupRepository {
|
||||||
|
private val log = Logger.withTag("AddonStreamWarmup")
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val cache = mutableMapOf<AddonStreamWarmupKey, CachedAddonStreamWarmup>()
|
||||||
|
private val inFlight = mutableMapOf<AddonStreamWarmupKey, Deferred<List<AddonStreamGroup>>>()
|
||||||
|
|
||||||
|
fun preload(type: String, videoId: String, season: Int? = null, episode: Int? = null) {
|
||||||
|
val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return
|
||||||
|
scope.launch {
|
||||||
|
runCatching { fetchWarmup(key) }
|
||||||
|
.onFailure { error ->
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
log.d(error) { "Addon stream warmup failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cachedGroups(type: String, videoId: String, season: Int? = null, episode: Int? = null): List<AddonStreamGroup>? {
|
||||||
|
val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return null
|
||||||
|
if (!mutex.tryLock()) return null
|
||||||
|
return try {
|
||||||
|
cachedGroupsLocked(key)
|
||||||
|
} finally {
|
||||||
|
mutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchWarmup(key: AddonStreamWarmupKey): List<AddonStreamGroup> {
|
||||||
|
cachedGroups(key.type, key.videoId, key.season, key.episode)?.let { return it }
|
||||||
|
|
||||||
|
var ownsFetch = false
|
||||||
|
val newFetch = scope.async(start = CoroutineStart.LAZY) {
|
||||||
|
fetchWarmupUncached(key)
|
||||||
|
}
|
||||||
|
val activeFetch = mutex.withLock {
|
||||||
|
cachedGroupsLocked(key)?.let { cached ->
|
||||||
|
return@withLock null to cached
|
||||||
|
}
|
||||||
|
val existing = inFlight[key]
|
||||||
|
if (existing != null) {
|
||||||
|
existing to null
|
||||||
|
} else {
|
||||||
|
inFlight[key] = newFetch
|
||||||
|
ownsFetch = true
|
||||||
|
newFetch to null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeFetch.second?.let {
|
||||||
|
newFetch.cancel()
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val deferred = activeFetch.first ?: return emptyList()
|
||||||
|
if (!ownsFetch) newFetch.cancel()
|
||||||
|
if (ownsFetch) deferred.start()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val result = deferred.await()
|
||||||
|
val cacheableGroups = result.filter { it.streams.isNotEmpty() }
|
||||||
|
if (ownsFetch && cacheableGroups.isNotEmpty()) {
|
||||||
|
mutex.withLock {
|
||||||
|
cache[key] = CachedAddonStreamWarmup(
|
||||||
|
groups = cacheableGroups,
|
||||||
|
createdAtMs = epochMs(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} finally {
|
||||||
|
if (ownsFetch) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (inFlight[key] === deferred) {
|
||||||
|
inFlight.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchWarmupUncached(key: AddonStreamWarmupKey): List<AddonStreamGroup> {
|
||||||
|
val targets = key.addonTargets
|
||||||
|
if (targets.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val addonIds = targets.map { it.addonId }.toSet()
|
||||||
|
val orderedGroups = coroutineScope {
|
||||||
|
targets.map { target ->
|
||||||
|
async {
|
||||||
|
val group = fetchAddonStreams(
|
||||||
|
target = target,
|
||||||
|
type = key.type,
|
||||||
|
videoId = key.videoId,
|
||||||
|
)
|
||||||
|
val eligibleGroupIds = setOf(group.addonId)
|
||||||
|
val checkingGroup = LocalDebridAvailabilityService.markChecking(
|
||||||
|
groups = listOf(group),
|
||||||
|
eligibleGroupIds = eligibleGroupIds,
|
||||||
|
).firstOrNull() ?: group
|
||||||
|
val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability(
|
||||||
|
groups = listOf(checkingGroup),
|
||||||
|
eligibleGroupIds = eligibleGroupIds,
|
||||||
|
).firstOrNull() ?: checkingGroup
|
||||||
|
DebridStreamPresentation.apply(
|
||||||
|
groups = listOf(availabilityGroup),
|
||||||
|
settings = key.settings,
|
||||||
|
).firstOrNull() ?: availabilityGroup
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}.let { groups ->
|
||||||
|
StreamAutoPlaySelector.orderAddonStreams(
|
||||||
|
groups = groups,
|
||||||
|
installedOrder = targets.map { it.addonName },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var preparedGroups = orderedGroups
|
||||||
|
|
||||||
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
|
DirectDebridStreamPreparer.prepare(
|
||||||
|
streams = preparedGroups.flatMap { it.streams },
|
||||||
|
season = key.season,
|
||||||
|
episode = key.episode,
|
||||||
|
playerSettings = PlayerSettingsRepository.uiState.value,
|
||||||
|
installedAddonNames = targets.map { it.addonName }.toSet(),
|
||||||
|
) { original, prepared ->
|
||||||
|
preparedGroups = DirectDebridStreamPreparer.replacePreparedStream(
|
||||||
|
groups = preparedGroups,
|
||||||
|
original = original,
|
||||||
|
prepared = prepared,
|
||||||
|
eligibleGroupIds = addonIds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return preparedGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchAddonStreams(
|
||||||
|
target: AddonStreamWarmupTarget,
|
||||||
|
type: String,
|
||||||
|
videoId: String,
|
||||||
|
): AddonStreamGroup {
|
||||||
|
val url = buildAddonResourceUrl(
|
||||||
|
manifestUrl = target.manifest.transportUrl,
|
||||||
|
resource = "stream",
|
||||||
|
type = type,
|
||||||
|
id = videoId,
|
||||||
|
)
|
||||||
|
return runCatchingUnlessCancelled {
|
||||||
|
val payload = httpGetText(url)
|
||||||
|
StreamParser.parse(
|
||||||
|
payload = payload,
|
||||||
|
addonName = target.addonName,
|
||||||
|
addonId = target.addonId,
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { streams ->
|
||||||
|
AddonStreamGroup(
|
||||||
|
addonName = target.addonName,
|
||||||
|
addonId = target.addonId,
|
||||||
|
streams = streams,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
log.d(error) { "Failed to warm addon stream target ${target.addonName}" }
|
||||||
|
AddonStreamGroup(
|
||||||
|
addonName = target.addonName,
|
||||||
|
addonId = target.addonId,
|
||||||
|
streams = emptyList(),
|
||||||
|
isLoading = false,
|
||||||
|
error = error.message,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentKey(type: String, videoId: String, season: Int?, episode: Int?): AddonStreamWarmupKey? {
|
||||||
|
val normalizedType = type.trim().lowercase()
|
||||||
|
val normalizedVideoId = videoId.trim()
|
||||||
|
if (normalizedType.isBlank() || normalizedVideoId.isBlank()) return null
|
||||||
|
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
val settings = DebridSettingsRepository.snapshot()
|
||||||
|
if (!settings.canResolvePlayableLinks || settings.torboxApiKey.isBlank()) return null
|
||||||
|
|
||||||
|
AddonRepository.initialize()
|
||||||
|
val addonTargets = AddonRepository.uiState.value.addons
|
||||||
|
.mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) }
|
||||||
|
if (addonTargets.isEmpty()) return null
|
||||||
|
|
||||||
|
return AddonStreamWarmupKey(
|
||||||
|
type = normalizedType,
|
||||||
|
videoId = normalizedVideoId,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
addonFingerprint = addonTargets.joinToString("|") { it.fingerprint },
|
||||||
|
settingsFingerprint = settings.warmupFingerprint(),
|
||||||
|
settings = settings,
|
||||||
|
addonTargets = addonTargets,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cachedGroupsLocked(key: AddonStreamWarmupKey): List<AddonStreamGroup>? {
|
||||||
|
val cached = cache[key] ?: return null
|
||||||
|
val age = epochMs() - cached.createdAtMs
|
||||||
|
return if (age in 0..ADDON_STREAM_WARMUP_CACHE_TTL_MS) {
|
||||||
|
cached.groups
|
||||||
|
} else {
|
||||||
|
cache.remove(key)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AddonStreamWarmupKey(
|
||||||
|
val type: String,
|
||||||
|
val videoId: String,
|
||||||
|
val season: Int?,
|
||||||
|
val episode: Int?,
|
||||||
|
val addonFingerprint: String,
|
||||||
|
val settingsFingerprint: String,
|
||||||
|
val settings: DebridSettings,
|
||||||
|
val addonTargets: List<AddonStreamWarmupTarget>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class AddonStreamWarmupTarget(
|
||||||
|
val addonName: String,
|
||||||
|
val addonId: String,
|
||||||
|
val manifest: AddonManifest,
|
||||||
|
val fingerprint: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class CachedAddonStreamWarmup(
|
||||||
|
val groups: List<AddonStreamGroup>,
|
||||||
|
val createdAtMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ManagedAddon.toWarmupTarget(type: String, videoId: String): AddonStreamWarmupTarget? {
|
||||||
|
val manifest = manifest ?: return null
|
||||||
|
val supportsRequestedStream = manifest.resources.any { resource ->
|
||||||
|
resource.name == "stream" &&
|
||||||
|
resource.types.contains(type) &&
|
||||||
|
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { videoId.startsWith(it) })
|
||||||
|
}
|
||||||
|
if (!supportsRequestedStream) return null
|
||||||
|
|
||||||
|
val addonName = displayTitle.ifBlank { manifest.name }
|
||||||
|
return AddonStreamWarmupTarget(
|
||||||
|
addonName = addonName,
|
||||||
|
addonId = "addon:${manifest.id}:$manifestUrl",
|
||||||
|
manifest = manifest,
|
||||||
|
fingerprint = "$manifestUrl:${manifest.id}:${manifest.version}:$addonName",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DebridSettings.warmupFingerprint(): String =
|
||||||
|
listOf(
|
||||||
|
enabled,
|
||||||
|
torboxApiKey,
|
||||||
|
instantPlaybackPreparationLimit,
|
||||||
|
streamMaxResults,
|
||||||
|
streamSortMode,
|
||||||
|
streamMinimumQuality,
|
||||||
|
streamDolbyVisionFilter,
|
||||||
|
streamHdrFilter,
|
||||||
|
streamCodecFilter,
|
||||||
|
streamPreferences,
|
||||||
|
streamNameTemplate,
|
||||||
|
streamDescriptionTemplate,
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
|
private suspend fun <T> runCatchingUnlessCancelled(block: suspend () -> T): Result<T> =
|
||||||
|
try {
|
||||||
|
Result.success(block())
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Result.failure(error)
|
||||||
|
}
|
||||||
|
|
@ -15,15 +15,19 @@ object StreamAutoPlaySelector {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (installedOrder.isEmpty()) return groups
|
val (directDebridEntries, remainingEntries) = groups.partition { group ->
|
||||||
|
group.addonId.startsWith("debrid:") ||
|
||||||
|
group.streams.any { stream -> stream.isAddonDebridCandidate && stream.isDirectDebridStream }
|
||||||
|
}
|
||||||
|
if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries
|
||||||
|
|
||||||
val (addonEntries, pluginEntries) = groups.partition { group ->
|
val (addonEntries, pluginEntries) = remainingEntries.partition { group ->
|
||||||
group.addonName in addonRankByName
|
group.addonName in addonRankByName
|
||||||
}
|
}
|
||||||
val orderedAddons = addonEntries.sortedBy { group ->
|
val orderedAddons = addonEntries.sortedBy { group ->
|
||||||
addonRankByName.getValue(group.addonName)
|
addonRankByName.getValue(group.addonName)
|
||||||
}
|
}
|
||||||
return orderedAddons + pluginEntries
|
return directDebridEntries + orderedAddons + pluginEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectAutoPlayStream(
|
fun selectAutoPlayStream(
|
||||||
|
|
@ -37,8 +41,39 @@ object StreamAutoPlaySelector {
|
||||||
preferredBingeGroup: String? = null,
|
preferredBingeGroup: String? = null,
|
||||||
preferBingeGroupInSelection: Boolean = false,
|
preferBingeGroupInSelection: Boolean = false,
|
||||||
bingeGroupOnly: Boolean = false,
|
bingeGroupOnly: Boolean = false,
|
||||||
): StreamItem? {
|
debridEnabled: Boolean = true,
|
||||||
if (streams.isEmpty()) return null
|
activeResolverProviderId: String? = null,
|
||||||
|
): StreamItem? =
|
||||||
|
evaluateAutoPlayStream(
|
||||||
|
streams = streams,
|
||||||
|
mode = mode,
|
||||||
|
regexPattern = regexPattern,
|
||||||
|
source = source,
|
||||||
|
installedAddonNames = installedAddonNames,
|
||||||
|
selectedAddons = selectedAddons,
|
||||||
|
selectedPlugins = selectedPlugins,
|
||||||
|
preferredBingeGroup = preferredBingeGroup,
|
||||||
|
preferBingeGroupInSelection = preferBingeGroupInSelection,
|
||||||
|
bingeGroupOnly = bingeGroupOnly,
|
||||||
|
debridEnabled = debridEnabled,
|
||||||
|
activeResolverProviderId = activeResolverProviderId,
|
||||||
|
).stream
|
||||||
|
|
||||||
|
fun evaluateAutoPlayStream(
|
||||||
|
streams: List<StreamItem>,
|
||||||
|
mode: StreamAutoPlayMode,
|
||||||
|
regexPattern: String,
|
||||||
|
source: StreamAutoPlaySource,
|
||||||
|
installedAddonNames: Set<String>,
|
||||||
|
selectedAddons: Set<String>,
|
||||||
|
selectedPlugins: Set<String>,
|
||||||
|
preferredBingeGroup: String? = null,
|
||||||
|
preferBingeGroupInSelection: Boolean = false,
|
||||||
|
bingeGroupOnly: Boolean = false,
|
||||||
|
debridEnabled: Boolean = true,
|
||||||
|
activeResolverProviderId: String? = null,
|
||||||
|
): StreamAutoPlayEvaluation {
|
||||||
|
if (streams.isEmpty()) return StreamAutoPlayEvaluation()
|
||||||
|
|
||||||
val sourceScopedStreams = when (source) {
|
val sourceScopedStreams = when (source) {
|
||||||
StreamAutoPlaySource.ALL_SOURCES -> streams
|
StreamAutoPlaySource.ALL_SOURCES -> streams
|
||||||
|
|
@ -53,31 +88,50 @@ object StreamAutoPlaySelector {
|
||||||
selectedPlugins.isEmpty() || stream.addonName in selectedPlugins
|
selectedPlugins.isEmpty() || stream.addonName in selectedPlugins
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (candidateStreams.isEmpty()) return null
|
if (candidateStreams.isEmpty()) return StreamAutoPlayEvaluation()
|
||||||
if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) return null
|
if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) {
|
||||||
|
return StreamAutoPlayEvaluation()
|
||||||
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
|
|
||||||
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
|
|
||||||
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
|
|
||||||
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable()
|
|
||||||
}
|
|
||||||
if (bingeGroupMatch != null) return bingeGroupMatch
|
|
||||||
// When bingeGroupOnly = true, do NOT fall through to mode-based selection
|
|
||||||
if (bingeGroupOnly) return null
|
|
||||||
} else if (bingeGroupOnly) {
|
|
||||||
// bingeGroupOnly requested but no preferredBingeGroup or preferBingeGroupInSelection is false
|
|
||||||
// Fall through to mode-based selection (bingeGroupOnly has no effect without a binge group to match)
|
|
||||||
if (mode == StreamAutoPlayMode.MANUAL) return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (mode) {
|
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
|
||||||
StreamAutoPlayMode.MANUAL -> null
|
val bingeGroupCandidates = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
|
||||||
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable() }
|
candidateStreams.filter { stream -> stream.behaviorHints.bingeGroup == targetBingeGroup }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
val preferredReadyStream = bingeGroupCandidates.firstOrNull { stream ->
|
||||||
|
stream.isAutoPlayable(debridEnabled, activeResolverProviderId)
|
||||||
|
}
|
||||||
|
if (bingeGroupOnly) {
|
||||||
|
val readyStreams = preferredReadyStream?.let(::listOf).orEmpty()
|
||||||
|
return StreamAutoPlayEvaluation(
|
||||||
|
stream = preferredReadyStream,
|
||||||
|
readyStreams = readyStreams,
|
||||||
|
hasPendingDebridCandidate = preferredReadyStream == null &&
|
||||||
|
bingeGroupCandidates.any {
|
||||||
|
it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mode == StreamAutoPlayMode.MANUAL) {
|
||||||
|
return StreamAutoPlayEvaluation()
|
||||||
|
}
|
||||||
|
val preferredStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
|
||||||
|
candidateStreams.firstOrNull { stream ->
|
||||||
|
stream.behaviorHints.bingeGroup == targetBingeGroup &&
|
||||||
|
stream.isAutoPlayable(debridEnabled, activeResolverProviderId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val matchingStreams = when (mode) {
|
||||||
|
StreamAutoPlayMode.MANUAL -> emptyList()
|
||||||
|
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams
|
||||||
StreamAutoPlayMode.REGEX_MATCH -> {
|
StreamAutoPlayMode.REGEX_MATCH -> {
|
||||||
val pattern = regexPattern.trim()
|
val pattern = regexPattern.trim()
|
||||||
|
|
||||||
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull()
|
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull()
|
||||||
?: return null
|
?: return StreamAutoPlayEvaluation()
|
||||||
|
|
||||||
val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern)
|
val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern)
|
||||||
|
|
||||||
|
|
@ -91,9 +145,8 @@ object StreamAutoPlaySelector {
|
||||||
Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE)
|
Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE)
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val matchingStreams = candidateStreams.filter { stream ->
|
candidateStreams.filter { stream ->
|
||||||
if (!stream.isAutoPlayable()) return@filter false
|
val url = stream.playableDirectUrl.orEmpty()
|
||||||
val url = stream.directPlaybackUrl.orEmpty()
|
|
||||||
|
|
||||||
val searchableText = buildString {
|
val searchableText = buildString {
|
||||||
append(stream.addonName).append(' ')
|
append(stream.addonName).append(' ')
|
||||||
|
|
@ -111,13 +164,65 @@ object StreamAutoPlaySelector {
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchingStreams.isEmpty()) return null
|
|
||||||
matchingStreams.firstOrNull { it.isAutoPlayable() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (matchingStreams.isEmpty() && preferredStream == null) return StreamAutoPlayEvaluation()
|
||||||
|
|
||||||
|
val readyStreams = buildList {
|
||||||
|
preferredStream?.let(::add)
|
||||||
|
matchingStreams
|
||||||
|
.filter { it.isAutoPlayable(debridEnabled, activeResolverProviderId) }
|
||||||
|
.filterNot { it == preferredStream }
|
||||||
|
.forEach(::add)
|
||||||
|
}
|
||||||
|
val selected = readyStreams.firstOrNull()
|
||||||
|
if (selected != null) {
|
||||||
|
return StreamAutoPlayEvaluation(
|
||||||
|
stream = selected,
|
||||||
|
readyStreams = readyStreams,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamAutoPlayEvaluation(
|
||||||
|
readyStreams = readyStreams,
|
||||||
|
hasPendingDebridCandidate = matchingStreams.any {
|
||||||
|
it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun StreamItem.isAutoPlayable(): Boolean =
|
private fun StreamItem.isAutoPlayable(
|
||||||
directPlaybackUrl != null
|
debridEnabled: Boolean,
|
||||||
|
activeResolverProviderId: String?,
|
||||||
|
): Boolean =
|
||||||
|
playableDirectUrl != null ||
|
||||||
|
(debridEnabled && isAddonDebridCandidate && isReadyDebridAutoPlay(activeResolverProviderId))
|
||||||
|
|
||||||
|
private fun StreamItem.isReadyDebridAutoPlay(activeResolverProviderId: String?): Boolean =
|
||||||
|
when {
|
||||||
|
isDirectDebridStream -> clientResolve?.service.matchesResolver(activeResolverProviderId)
|
||||||
|
isCachedDebridTorrentStream -> debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StreamItem.isPendingDebridAutoPlay(
|
||||||
|
debridEnabled: Boolean,
|
||||||
|
activeResolverProviderId: String?,
|
||||||
|
): Boolean {
|
||||||
|
if (!debridEnabled || !isInstalledAddonStream || !needsLocalDebridResolve) return false
|
||||||
|
if (!debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId)) return false
|
||||||
|
val state = debridCacheStatus?.state
|
||||||
|
return state == null || state == StreamDebridCacheState.CHECKING
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.matchesResolver(activeResolverProviderId: String?): Boolean {
|
||||||
|
val active = activeResolverProviderId?.trim().orEmpty()
|
||||||
|
return active.isBlank() || this == null || equals(active, ignoreCase = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class StreamAutoPlayEvaluation(
|
||||||
|
val stream: StreamItem? = null,
|
||||||
|
val readyStreams: List<StreamItem> = emptyList(),
|
||||||
|
val hasPendingDebridCandidate: Boolean = false,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ data class StreamItem(
|
||||||
val addonName: String,
|
val addonName: String,
|
||||||
val addonId: String,
|
val addonId: String,
|
||||||
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
|
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
|
||||||
|
val clientResolve: StreamClientResolve? = null,
|
||||||
|
val debridCacheStatus: StreamDebridCacheStatus? = null,
|
||||||
) {
|
) {
|
||||||
val streamLabel: String
|
val streamLabel: String
|
||||||
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
|
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
|
||||||
|
|
@ -27,18 +29,46 @@ data class StreamItem(
|
||||||
val directPlaybackUrl: String?
|
val directPlaybackUrl: String?
|
||||||
get() = url ?: externalUrl
|
get() = url ?: externalUrl
|
||||||
|
|
||||||
|
val playableDirectUrl: String?
|
||||||
|
get() = listOfNotNull(url, externalUrl)
|
||||||
|
.firstOrNull { !it.isMagnetLink() }
|
||||||
|
|
||||||
|
val torrentMagnetUri: String?
|
||||||
|
get() = listOfNotNull(url, externalUrl)
|
||||||
|
.firstOrNull { it.isMagnetLink() }
|
||||||
|
|
||||||
|
val isDirectDebridStream: Boolean
|
||||||
|
get() = clientResolve?.isDirectDebridCandidate == true
|
||||||
|
|
||||||
|
val isInstalledAddonStream: Boolean
|
||||||
|
get() = addonId.startsWith("addon:")
|
||||||
|
|
||||||
val isTorrentStream: Boolean
|
val isTorrentStream: Boolean
|
||||||
get() = !infoHash.isNullOrBlank() ||
|
get() = !isDirectDebridStream && (
|
||||||
|
!infoHash.isNullOrBlank() ||
|
||||||
url.isMagnetLink() ||
|
url.isMagnetLink() ||
|
||||||
externalUrl.isMagnetLink()
|
externalUrl.isMagnetLink()
|
||||||
|
)
|
||||||
|
|
||||||
|
val isCachedDebridTorrentStream: Boolean
|
||||||
|
get() = isTorrentStream && debridCacheStatus?.state == StreamDebridCacheState.CACHED
|
||||||
|
|
||||||
|
val needsLocalDebridResolve: Boolean
|
||||||
|
get() = isTorrentStream && playableDirectUrl == null
|
||||||
|
|
||||||
|
val isAddonDebridCandidate: Boolean
|
||||||
|
get() = isInstalledAddonStream && (needsLocalDebridResolve || isDirectDebridStream)
|
||||||
|
|
||||||
val hasPlayableSource: Boolean
|
val hasPlayableSource: Boolean
|
||||||
get() = url != null || infoHash != null || externalUrl != null
|
get() = url != null || infoHash != null || externalUrl != null || clientResolve != null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String?.isMagnetLink(): Boolean =
|
private fun String?.isMagnetLink(): Boolean =
|
||||||
this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true
|
this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true
|
||||||
|
|
||||||
|
fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean =
|
||||||
|
playableDirectUrl != null || (debridEnabled && isAddonDebridCandidate)
|
||||||
|
|
||||||
data class StreamBehaviorHints(
|
data class StreamBehaviorHints(
|
||||||
val bingeGroup: String? = null,
|
val bingeGroup: String? = null,
|
||||||
val notWebReady: Boolean = false,
|
val notWebReady: Boolean = false,
|
||||||
|
|
@ -53,6 +83,86 @@ data class StreamProxyHeaders(
|
||||||
val response: Map<String, String>? = null,
|
val response: Map<String, String>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class StreamDebridCacheState {
|
||||||
|
CHECKING,
|
||||||
|
CACHED,
|
||||||
|
NOT_CACHED,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StreamDebridCacheStatus(
|
||||||
|
val providerId: String,
|
||||||
|
val providerName: String,
|
||||||
|
val state: StreamDebridCacheState,
|
||||||
|
val cachedName: String? = null,
|
||||||
|
val cachedSize: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StreamClientResolve(
|
||||||
|
val type: String? = null,
|
||||||
|
val infoHash: String? = null,
|
||||||
|
val fileIdx: Int? = null,
|
||||||
|
val magnetUri: String? = null,
|
||||||
|
val sources: List<String> = emptyList(),
|
||||||
|
val torrentName: String? = null,
|
||||||
|
val filename: String? = null,
|
||||||
|
val mediaType: String? = null,
|
||||||
|
val mediaId: String? = null,
|
||||||
|
val mediaOnlyId: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val season: Int? = null,
|
||||||
|
val episode: Int? = null,
|
||||||
|
val service: String? = null,
|
||||||
|
val serviceIndex: Int? = null,
|
||||||
|
val serviceExtension: String? = null,
|
||||||
|
val isCached: Boolean? = null,
|
||||||
|
val stream: StreamClientResolveStream? = null,
|
||||||
|
) {
|
||||||
|
val isDirectDebridCandidate: Boolean
|
||||||
|
get() = type.equals("debrid", ignoreCase = true) &&
|
||||||
|
!service.isNullOrBlank() &&
|
||||||
|
isCached == true
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StreamClientResolveStream(
|
||||||
|
val raw: StreamClientResolveRaw? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StreamClientResolveRaw(
|
||||||
|
val torrentName: String? = null,
|
||||||
|
val filename: String? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
val folderSize: Long? = null,
|
||||||
|
val tracker: String? = null,
|
||||||
|
val indexer: String? = null,
|
||||||
|
val network: String? = null,
|
||||||
|
val parsed: StreamClientResolveParsed? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StreamClientResolveParsed(
|
||||||
|
val rawTitle: String? = null,
|
||||||
|
val parsedTitle: String? = null,
|
||||||
|
val year: Int? = null,
|
||||||
|
val resolution: String? = null,
|
||||||
|
val seasons: List<Int> = emptyList(),
|
||||||
|
val episodes: List<Int> = emptyList(),
|
||||||
|
val quality: String? = null,
|
||||||
|
val hdr: List<String> = emptyList(),
|
||||||
|
val codec: String? = null,
|
||||||
|
val audio: List<String> = emptyList(),
|
||||||
|
val channels: List<String> = emptyList(),
|
||||||
|
val languages: List<String> = emptyList(),
|
||||||
|
val group: String? = null,
|
||||||
|
val network: String? = null,
|
||||||
|
val edition: String? = null,
|
||||||
|
val duration: Long? = null,
|
||||||
|
val bitDepth: String? = null,
|
||||||
|
val extended: Boolean? = null,
|
||||||
|
val theatrical: Boolean? = null,
|
||||||
|
val remastered: Boolean? = null,
|
||||||
|
val unrated: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class AddonStreamGroup(
|
data class AddonStreamGroup(
|
||||||
val addonName: String,
|
val addonName: String,
|
||||||
val addonId: String,
|
val addonId: String,
|
||||||
|
|
@ -76,6 +186,7 @@ data class StreamsUiState(
|
||||||
val isAnyLoading: Boolean = false,
|
val isAnyLoading: Boolean = false,
|
||||||
val emptyStateReason: StreamsEmptyStateReason? = null,
|
val emptyStateReason: StreamsEmptyStateReason? = null,
|
||||||
val autoPlayStream: StreamItem? = null,
|
val autoPlayStream: StreamItem? = null,
|
||||||
|
val autoPlayCandidates: List<StreamItem> = emptyList(),
|
||||||
val isDirectAutoPlayFlow: Boolean = false,
|
val isDirectAutoPlayFlow: Boolean = false,
|
||||||
val showDirectAutoPlayOverlay: Boolean = false,
|
val showDirectAutoPlayOverlay: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@ object StreamParser {
|
||||||
val url = obj.string("url")
|
val url = obj.string("url")
|
||||||
val infoHash = obj.string("infoHash")
|
val infoHash = obj.string("infoHash")
|
||||||
val externalUrl = obj.string("externalUrl")
|
val externalUrl = obj.string("externalUrl")
|
||||||
|
val clientResolve = obj.objectValue("clientResolve")?.toClientResolve()
|
||||||
|
|
||||||
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
|
// Must have at least one playable source
|
||||||
|
if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null
|
||||||
|
|
||||||
val hintsObj = obj["behaviorHints"] as? JsonObject
|
val hintsObj = obj["behaviorHints"] as? JsonObject
|
||||||
val proxyHeaders = hintsObj
|
val proxyHeaders = hintsObj
|
||||||
|
|
@ -44,6 +46,7 @@ object StreamParser {
|
||||||
sources = obj.stringList("sources"),
|
sources = obj.stringList("sources"),
|
||||||
addonName = addonName,
|
addonName = addonName,
|
||||||
addonId = addonId,
|
addonId = addonId,
|
||||||
|
clientResolve = clientResolve,
|
||||||
behaviorHints = StreamBehaviorHints(
|
behaviorHints = StreamBehaviorHints(
|
||||||
bingeGroup = hintsObj?.string("bingeGroup"),
|
bingeGroup = hintsObj?.string("bingeGroup"),
|
||||||
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
|
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
|
||||||
|
|
@ -80,6 +83,11 @@ object StreamParser {
|
||||||
?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
|
?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
|
private fun JsonObject.intList(name: String): List<Int> =
|
||||||
|
(this[name] as? JsonArray)
|
||||||
|
?.mapNotNull { it.jsonPrimitive.intOrNull }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
private fun JsonObject.stringMap(): Map<String, String> =
|
private fun JsonObject.stringMap(): Map<String, String> =
|
||||||
entries.mapNotNull { (key, value) ->
|
entries.mapNotNull { (key, value) ->
|
||||||
(value as? JsonPrimitive)?.contentOrNull
|
(value as? JsonPrimitive)?.contentOrNull
|
||||||
|
|
@ -99,4 +107,67 @@ object StreamParser {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.toClientResolve(): StreamClientResolve =
|
||||||
|
StreamClientResolve(
|
||||||
|
type = string("type"),
|
||||||
|
infoHash = string("infoHash"),
|
||||||
|
fileIdx = int("fileIdx"),
|
||||||
|
magnetUri = string("magnetUri"),
|
||||||
|
sources = stringList("sources"),
|
||||||
|
torrentName = string("torrentName"),
|
||||||
|
filename = string("filename"),
|
||||||
|
mediaType = string("mediaType"),
|
||||||
|
mediaId = string("mediaId"),
|
||||||
|
mediaOnlyId = string("mediaOnlyId"),
|
||||||
|
title = string("title"),
|
||||||
|
season = int("season"),
|
||||||
|
episode = int("episode"),
|
||||||
|
service = string("service"),
|
||||||
|
serviceIndex = int("serviceIndex"),
|
||||||
|
serviceExtension = string("serviceExtension"),
|
||||||
|
isCached = boolean("isCached"),
|
||||||
|
stream = objectValue("stream")?.toClientResolveStream(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JsonObject.toClientResolveStream(): StreamClientResolveStream =
|
||||||
|
StreamClientResolveStream(
|
||||||
|
raw = objectValue("raw")?.toClientResolveRaw(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JsonObject.toClientResolveRaw(): StreamClientResolveRaw =
|
||||||
|
StreamClientResolveRaw(
|
||||||
|
torrentName = string("torrentName"),
|
||||||
|
filename = string("filename"),
|
||||||
|
size = long("size"),
|
||||||
|
folderSize = long("folderSize"),
|
||||||
|
tracker = string("tracker"),
|
||||||
|
indexer = string("indexer"),
|
||||||
|
network = string("network"),
|
||||||
|
parsed = objectValue("parsed")?.toClientResolveParsed(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JsonObject.toClientResolveParsed(): StreamClientResolveParsed =
|
||||||
|
StreamClientResolveParsed(
|
||||||
|
rawTitle = string("raw_title"),
|
||||||
|
parsedTitle = string("parsed_title"),
|
||||||
|
year = int("year"),
|
||||||
|
resolution = string("resolution"),
|
||||||
|
seasons = intList("seasons"),
|
||||||
|
episodes = intList("episodes"),
|
||||||
|
quality = string("quality"),
|
||||||
|
hdr = stringList("hdr"),
|
||||||
|
codec = string("codec"),
|
||||||
|
audio = stringList("audio"),
|
||||||
|
channels = stringList("channels"),
|
||||||
|
languages = stringList("languages"),
|
||||||
|
group = string("group"),
|
||||||
|
network = string("network"),
|
||||||
|
edition = string("edition"),
|
||||||
|
duration = long("duration"),
|
||||||
|
bitDepth = string("bit_depth"),
|
||||||
|
extended = boolean("extended"),
|
||||||
|
theatrical = boolean("theatrical"),
|
||||||
|
remastered = boolean("remastered"),
|
||||||
|
unrated = boolean("unrated"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
|
import com.nuvio.app.features.debrid.DebridStreamPresentation
|
||||||
|
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.plugins.PluginRepository
|
import com.nuvio.app.features.plugins.PluginRepository
|
||||||
|
|
@ -101,6 +105,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
PlayerSettingsRepository.ensureLoaded()
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
val playerSettings = PlayerSettingsRepository.uiState.value
|
val playerSettings = PlayerSettingsRepository.uiState.value
|
||||||
|
val debridSettings = DebridSettingsRepository.snapshot()
|
||||||
val autoPlayMode = playerSettings.streamAutoPlayMode
|
val autoPlayMode = playerSettings.streamAutoPlayMode
|
||||||
val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL &&
|
val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL &&
|
||||||
!(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH &&
|
!(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH &&
|
||||||
|
|
@ -198,8 +203,13 @@ object StreamsRepository {
|
||||||
|
|
||||||
// Initialise loading placeholders
|
// Initialise loading placeholders
|
||||||
val installedAddonOrder = streamAddons.map { it.addonName }
|
val installedAddonOrder = streamAddons.map { it.addonName }
|
||||||
|
val warmedAddonGroups = AddonStreamWarmupRepository
|
||||||
|
.cachedGroups(type = type, videoId = videoId, season = season, episode = episode)
|
||||||
|
.orEmpty()
|
||||||
|
.associateBy { it.addonId }
|
||||||
|
val warmedAddonIds = warmedAddonGroups.keys
|
||||||
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
|
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
|
||||||
AddonStreamGroup(
|
warmedAddonGroups[addon.addonId] ?: AddonStreamGroup(
|
||||||
addonName = addon.addonName,
|
addonName = addon.addonName,
|
||||||
addonId = addon.addonId,
|
addonId = addon.addonId,
|
||||||
streams = emptyList(),
|
streams = emptyList(),
|
||||||
|
|
@ -213,26 +223,30 @@ object StreamsRepository {
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
)
|
)
|
||||||
}, installedAddonOrder)
|
}, installedAddonOrder)
|
||||||
|
val isInitiallyLoading = initialGroups.any { it.isLoading }
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
requestToken = requestToken,
|
requestToken = requestToken,
|
||||||
groups = initialGroups,
|
groups = initialGroups,
|
||||||
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
||||||
isAnyLoading = true,
|
isAnyLoading = isInitiallyLoading,
|
||||||
emptyStateReason = null,
|
emptyStateReason = null,
|
||||||
isDirectAutoPlayFlow = isDirectAutoPlayFlow,
|
isDirectAutoPlayFlow = isDirectAutoPlayFlow,
|
||||||
showDirectAutoPlayOverlay = isDirectAutoPlayFlow,
|
showDirectAutoPlayOverlay = isDirectAutoPlayFlow,
|
||||||
)
|
)
|
||||||
|
|
||||||
activeJob = scope.launch {
|
activeJob = scope.launch {
|
||||||
|
val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds }
|
||||||
val completions = Channel<StreamLoadCompletion>(capacity = Channel.BUFFERED)
|
val completions = Channel<StreamLoadCompletion>(capacity = Channel.BUFFERED)
|
||||||
val pluginRemainingByAddonId = pluginProviderGroups
|
val pluginRemainingByAddonId = pluginProviderGroups
|
||||||
.associate { it.addonId to it.scrapers.size }
|
.associate { it.addonId to it.scrapers.size }
|
||||||
.toMutableMap()
|
.toMutableMap()
|
||||||
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
|
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
|
||||||
val totalTasks = streamAddons.size +
|
val totalTasks = pendingStreamAddons.size +
|
||||||
pluginProviderGroups.sumOf { it.scrapers.size }
|
pluginProviderGroups.sumOf { it.scrapers.size }
|
||||||
|
|
||||||
val installedAddonNames = installedAddonOrder.toSet()
|
val installedAddonNames = installedAddonOrder.toSet()
|
||||||
|
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
|
||||||
|
val debridAvailabilityJobs = mutableListOf<Job>()
|
||||||
var autoSelectTriggered = false
|
var autoSelectTriggered = false
|
||||||
var timeoutElapsed = false
|
var timeoutElapsed = false
|
||||||
fun publishCompletion(completion: StreamLoadCompletion) {
|
fun publishCompletion(completion: StreamLoadCompletion) {
|
||||||
|
|
@ -240,6 +254,48 @@ object StreamsRepository {
|
||||||
log.d { "Ignoring late stream load completion after channel close" }
|
log.d { "Ignoring late stream load completion after channel close" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun presentDebridGroup(group: AddonStreamGroup): AddonStreamGroup =
|
||||||
|
DebridStreamPresentation.apply(
|
||||||
|
groups = listOf(group),
|
||||||
|
settings = debridSettings,
|
||||||
|
).firstOrNull() ?: group
|
||||||
|
|
||||||
|
fun publishAddonGroup(group: AddonStreamGroup) {
|
||||||
|
_uiState.update { current ->
|
||||||
|
val updated = StreamAutoPlaySelector.orderAddonStreams(
|
||||||
|
groups = current.groups.map { currentGroup ->
|
||||||
|
if (currentGroup.addonId == group.addonId) group else currentGroup
|
||||||
|
},
|
||||||
|
installedOrder = installedAddonOrder,
|
||||||
|
)
|
||||||
|
val anyLoading = updated.any { it.isLoading }
|
||||||
|
current.copy(
|
||||||
|
groups = updated,
|
||||||
|
isAnyLoading = anyLoading,
|
||||||
|
emptyStateReason = updated.toEmptyStateReason(anyLoading),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchDebridAvailability(group: AddonStreamGroup) {
|
||||||
|
if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return
|
||||||
|
|
||||||
|
val eligibleGroupIds = setOf(group.addonId)
|
||||||
|
val checkingGroup = LocalDebridAvailabilityService.markChecking(
|
||||||
|
groups = listOf(group),
|
||||||
|
eligibleGroupIds = eligibleGroupIds,
|
||||||
|
).firstOrNull() ?: group
|
||||||
|
publishAddonGroup(checkingGroup)
|
||||||
|
|
||||||
|
val availabilityJob = launch {
|
||||||
|
val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability(
|
||||||
|
groups = listOf(checkingGroup),
|
||||||
|
eligibleGroupIds = eligibleGroupIds,
|
||||||
|
).firstOrNull() ?: checkingGroup
|
||||||
|
publishAddonGroup(presentDebridGroup(availabilityGroup))
|
||||||
|
}
|
||||||
|
debridAvailabilityJobs += availabilityJob
|
||||||
|
}
|
||||||
|
|
||||||
val timeoutJob = if (isDirectAutoPlayFlow) {
|
val timeoutJob = if (isDirectAutoPlayFlow) {
|
||||||
val timeoutSeconds = playerSettings.streamAutoPlayTimeoutSeconds
|
val timeoutSeconds = playerSettings.streamAutoPlayTimeoutSeconds
|
||||||
|
|
@ -270,6 +326,8 @@ object StreamsRepository {
|
||||||
preferredBingeGroup = persistedBingeGroup,
|
preferredBingeGroup = persistedBingeGroup,
|
||||||
preferBingeGroupInSelection = persistedBingeGroup != null,
|
preferBingeGroupInSelection = persistedBingeGroup != null,
|
||||||
bingeGroupOnly = false,
|
bingeGroupOnly = false,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
activeResolverProviderId = debridSettings.activeResolverProviderId,
|
||||||
)
|
)
|
||||||
_uiState.update { it.copy(autoPlayStream = selected) }
|
_uiState.update { it.copy(autoPlayStream = selected) }
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +352,7 @@ object StreamsRepository {
|
||||||
if (!autoSelectTriggered) {
|
if (!autoSelectTriggered) {
|
||||||
val allStreams = _uiState.value.groups.flatMap { it.streams }
|
val allStreams = _uiState.value.groups.flatMap { it.streams }
|
||||||
if (allStreams.isNotEmpty()) {
|
if (allStreams.isNotEmpty()) {
|
||||||
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
|
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
|
||||||
streams = allStreams,
|
streams = allStreams,
|
||||||
mode = autoPlayMode,
|
mode = autoPlayMode,
|
||||||
regexPattern = playerSettings.streamAutoPlayRegex,
|
regexPattern = playerSettings.streamAutoPlayRegex,
|
||||||
|
|
@ -305,11 +363,19 @@ object StreamsRepository {
|
||||||
preferredBingeGroup = persistedBingeGroup,
|
preferredBingeGroup = persistedBingeGroup,
|
||||||
preferBingeGroupInSelection = persistedBingeGroup != null,
|
preferBingeGroupInSelection = persistedBingeGroup != null,
|
||||||
bingeGroupOnly = false,
|
bingeGroupOnly = false,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
activeResolverProviderId = debridSettings.activeResolverProviderId,
|
||||||
)
|
)
|
||||||
if (selected != null) {
|
if (evaluation.stream != null || !evaluation.hasPendingDebridCandidate) {
|
||||||
autoSelectTriggered = true
|
autoSelectTriggered = true
|
||||||
_uiState.update { it.copy(autoPlayStream = selected) }
|
_uiState.update {
|
||||||
} else {
|
it.copy(
|
||||||
|
autoPlayStream = evaluation.stream,
|
||||||
|
autoPlayCandidates = evaluation.readyStreams,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (evaluation.stream == null && !evaluation.hasPendingDebridCandidate) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isDirectAutoPlayFlow = false,
|
isDirectAutoPlayFlow = false,
|
||||||
|
|
@ -325,7 +391,7 @@ object StreamsRepository {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
streamAddons.forEach { addon ->
|
pendingStreamAddons.forEach { addon ->
|
||||||
launch {
|
launch {
|
||||||
val url = buildAddonResourceUrl(
|
val url = buildAddonResourceUrl(
|
||||||
manifestUrl = addon.manifest.transportUrl,
|
manifestUrl = addon.manifest.transportUrl,
|
||||||
|
|
@ -414,20 +480,8 @@ object StreamsRepository {
|
||||||
when (val completion = completions.receive()) {
|
when (val completion = completions.receive()) {
|
||||||
is StreamLoadCompletion.Addon -> {
|
is StreamLoadCompletion.Addon -> {
|
||||||
val result = completion.group
|
val result = completion.group
|
||||||
_uiState.update { current ->
|
publishAddonGroup(result)
|
||||||
val updated = StreamAutoPlaySelector.orderAddonStreams(
|
launchDebridAvailability(result)
|
||||||
groups = current.groups.map { group ->
|
|
||||||
if (group.addonId == result.addonId) result else group
|
|
||||||
},
|
|
||||||
installedOrder = installedAddonOrder,
|
|
||||||
)
|
|
||||||
val anyLoading = updated.any { it.isLoading }
|
|
||||||
current.copy(
|
|
||||||
groups = updated,
|
|
||||||
isAnyLoading = anyLoading,
|
|
||||||
emptyStateReason = updated.toEmptyStateReason(anyLoading),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is StreamLoadCompletion.PluginScraper -> {
|
is StreamLoadCompletion.PluginScraper -> {
|
||||||
|
|
@ -473,6 +527,32 @@ object StreamsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (availabilityJob in debridAvailabilityJobs) {
|
||||||
|
availabilityJob.join()
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
DirectDebridStreamPreparer.prepare(
|
||||||
|
streams = _uiState.value.groups
|
||||||
|
.filter { it.addonId in installedAddonIds }
|
||||||
|
.flatMap { it.streams },
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
playerSettings = playerSettings,
|
||||||
|
installedAddonNames = installedAddonNames,
|
||||||
|
) { original, prepared ->
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
groups = DirectDebridStreamPreparer.replacePreparedStream(
|
||||||
|
groups = current.groups,
|
||||||
|
original = original,
|
||||||
|
prepared = prepared,
|
||||||
|
eligibleGroupIds = installedAddonIds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Early match / timeout-elapsed auto-select on each addon response
|
// Early match / timeout-elapsed auto-select on each addon response
|
||||||
if (isDirectAutoPlayFlow && !autoSelectTriggered) {
|
if (isDirectAutoPlayFlow && !autoSelectTriggered) {
|
||||||
|
|
@ -491,6 +571,8 @@ object StreamsRepository {
|
||||||
preferredBingeGroup = persistedBingeGroup,
|
preferredBingeGroup = persistedBingeGroup,
|
||||||
preferBingeGroupInSelection = persistedBingeGroup != null,
|
preferBingeGroupInSelection = persistedBingeGroup != null,
|
||||||
bingeGroupOnly = false,
|
bingeGroupOnly = false,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
activeResolverProviderId = debridSettings.activeResolverProviderId,
|
||||||
)
|
)
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
autoSelectTriggered = true
|
autoSelectTriggered = true
|
||||||
|
|
@ -509,6 +591,8 @@ object StreamsRepository {
|
||||||
preferredBingeGroup = persistedBingeGroup,
|
preferredBingeGroup = persistedBingeGroup,
|
||||||
preferBingeGroupInSelection = true,
|
preferBingeGroupInSelection = true,
|
||||||
bingeGroupOnly = true,
|
bingeGroupOnly = true,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
activeResolverProviderId = debridSettings.activeResolverProviderId,
|
||||||
)
|
)
|
||||||
if (earlyMatch != null) {
|
if (earlyMatch != null) {
|
||||||
autoSelectTriggered = true
|
autoSelectTriggered = true
|
||||||
|
|
@ -523,7 +607,7 @@ object StreamsRepository {
|
||||||
if (isDirectAutoPlayFlow && !autoSelectTriggered) {
|
if (isDirectAutoPlayFlow && !autoSelectTriggered) {
|
||||||
autoSelectTriggered = true
|
autoSelectTriggered = true
|
||||||
val allStreams = _uiState.value.groups.flatMap { it.streams }
|
val allStreams = _uiState.value.groups.flatMap { it.streams }
|
||||||
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
|
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
|
||||||
streams = allStreams,
|
streams = allStreams,
|
||||||
mode = autoPlayMode,
|
mode = autoPlayMode,
|
||||||
regexPattern = playerSettings.streamAutoPlayRegex,
|
regexPattern = playerSettings.streamAutoPlayRegex,
|
||||||
|
|
@ -534,8 +618,15 @@ object StreamsRepository {
|
||||||
preferredBingeGroup = persistedBingeGroup,
|
preferredBingeGroup = persistedBingeGroup,
|
||||||
preferBingeGroupInSelection = persistedBingeGroup != null,
|
preferBingeGroupInSelection = persistedBingeGroup != null,
|
||||||
bingeGroupOnly = false,
|
bingeGroupOnly = false,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
activeResolverProviderId = debridSettings.activeResolverProviderId,
|
||||||
)
|
)
|
||||||
_uiState.update { it.copy(autoPlayStream = selected) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
autoPlayStream = evaluation.stream,
|
||||||
|
autoPlayCandidates = evaluation.readyStreams,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isDirectAutoPlayFlow && _uiState.value.autoPlayStream == null) {
|
if (isDirectAutoPlayFlow && _uiState.value.autoPlayStream == null) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
|
@ -558,12 +649,33 @@ object StreamsRepository {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
autoPlayStream = null,
|
autoPlayStream = null,
|
||||||
|
autoPlayCandidates = emptyList(),
|
||||||
isDirectAutoPlayFlow = false,
|
isDirectAutoPlayFlow = false,
|
||||||
showDirectAutoPlayOverlay = false,
|
showDirectAutoPlayOverlay = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipAutoPlayStream(stream: StreamItem): Boolean {
|
||||||
|
var hasNext = false
|
||||||
|
_uiState.update { current ->
|
||||||
|
val failedIndex = current.autoPlayCandidates.indexOf(stream)
|
||||||
|
val remaining = if (failedIndex >= 0) {
|
||||||
|
current.autoPlayCandidates.drop(failedIndex + 1)
|
||||||
|
} else {
|
||||||
|
current.autoPlayCandidates.drop(1)
|
||||||
|
}
|
||||||
|
hasNext = remaining.isNotEmpty()
|
||||||
|
current.copy(
|
||||||
|
autoPlayStream = remaining.firstOrNull(),
|
||||||
|
autoPlayCandidates = remaining,
|
||||||
|
isDirectAutoPlayFlow = remaining.isNotEmpty(),
|
||||||
|
showDirectAutoPlayOverlay = remaining.isNotEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return hasNext
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelLoading() {
|
fun cancelLoading() {
|
||||||
activeJob?.cancel()
|
activeJob?.cancel()
|
||||||
activeJob = null
|
activeJob = null
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ package com.nuvio.app.features.streams
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.MutableTransitionState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
|
@ -85,6 +88,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviders
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -130,6 +135,10 @@ fun StreamsScreen(
|
||||||
PlayerSettingsRepository.ensureLoaded()
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
PlayerSettingsRepository.uiState
|
PlayerSettingsRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val debridSettings by remember {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
DebridSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val watchProgressUiState by remember {
|
val watchProgressUiState by remember {
|
||||||
WatchProgressRepository.ensureLoaded()
|
WatchProgressRepository.ensureLoaded()
|
||||||
WatchProgressRepository.uiState
|
WatchProgressRepository.uiState
|
||||||
|
|
@ -141,7 +150,6 @@ fun StreamsScreen(
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
val streamLinkCopiedText = stringResource(Res.string.streams_link_copied)
|
val streamLinkCopiedText = stringResource(Res.string.streams_link_copied)
|
||||||
val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link)
|
val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link)
|
||||||
val torrentUnsupportedText = stringResource(Res.string.streams_torrent_not_supported)
|
|
||||||
var streamActionsTarget by remember(videoId) { mutableStateOf<StreamItem?>(null) }
|
var streamActionsTarget by remember(videoId) { mutableStateOf<StreamItem?>(null) }
|
||||||
var preferredFilterApplied by remember(videoId) { mutableStateOf(false) }
|
var preferredFilterApplied by remember(videoId) { mutableStateOf(false) }
|
||||||
val storedProgress = if (startFromBeginning) {
|
val storedProgress = if (startFromBeginning) {
|
||||||
|
|
@ -217,14 +225,12 @@ fun StreamsScreen(
|
||||||
episodeNumber = episodeNumber,
|
episodeNumber = episodeNumber,
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && !debridSettings.hasCustomStreamFormatting,
|
||||||
resumePositionMs = effectiveResumePositionMs,
|
resumePositionMs = effectiveResumePositionMs,
|
||||||
resumeProgressFraction = effectiveResumeProgressFraction,
|
resumeProgressFraction = effectiveResumeProgressFraction,
|
||||||
onStreamSelected = { stream, positionMs, progressFraction ->
|
onStreamSelected = { stream, positionMs, progressFraction ->
|
||||||
if (stream.isTorrentStream) {
|
onStreamSelected(stream, positionMs, progressFraction)
|
||||||
NuvioToastController.show(torrentUnsupportedText)
|
|
||||||
} else {
|
|
||||||
onStreamSelected(stream, positionMs, progressFraction)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onStreamLongPress = { stream -> streamActionsTarget = stream },
|
onStreamLongPress = { stream -> streamActionsTarget = stream },
|
||||||
)
|
)
|
||||||
|
|
@ -238,14 +244,12 @@ fun StreamsScreen(
|
||||||
episodeNumber = episodeNumber,
|
episodeNumber = episodeNumber,
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
debridEnabled = debridSettings.canResolvePlayableLinks,
|
||||||
|
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && !debridSettings.hasCustomStreamFormatting,
|
||||||
resumePositionMs = effectiveResumePositionMs,
|
resumePositionMs = effectiveResumePositionMs,
|
||||||
resumeProgressFraction = effectiveResumeProgressFraction,
|
resumeProgressFraction = effectiveResumeProgressFraction,
|
||||||
onStreamSelected = { stream, positionMs, progressFraction ->
|
onStreamSelected = { stream, positionMs, progressFraction ->
|
||||||
if (stream.isTorrentStream) {
|
onStreamSelected(stream, positionMs, progressFraction)
|
||||||
NuvioToastController.show(torrentUnsupportedText)
|
|
||||||
} else {
|
|
||||||
onStreamSelected(stream, positionMs, progressFraction)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onStreamLongPress = { stream -> streamActionsTarget = stream },
|
onStreamLongPress = { stream -> streamActionsTarget = stream },
|
||||||
)
|
)
|
||||||
|
|
@ -340,7 +344,7 @@ fun StreamsScreen(
|
||||||
externalPlayerEnabled = playerSettings.externalPlayerEnabled,
|
externalPlayerEnabled = playerSettings.externalPlayerEnabled,
|
||||||
onDismiss = { streamActionsTarget = null },
|
onDismiss = { streamActionsTarget = null },
|
||||||
onCopyLink = { stream ->
|
onCopyLink = { stream ->
|
||||||
val directUrl = stream.directPlaybackUrl
|
val directUrl = stream.playableDirectUrl
|
||||||
if (!directUrl.isNullOrBlank()) {
|
if (!directUrl.isNullOrBlank()) {
|
||||||
clipboardManager.setText(AnnotatedString(directUrl))
|
clipboardManager.setText(AnnotatedString(directUrl))
|
||||||
NuvioToastController.show(streamLinkCopiedText)
|
NuvioToastController.show(streamLinkCopiedText)
|
||||||
|
|
@ -388,6 +392,8 @@ private fun MobileStreamsLayout(
|
||||||
episodeNumber: Int?,
|
episodeNumber: Int?,
|
||||||
episodeTitle: String?,
|
episodeTitle: String?,
|
||||||
uiState: StreamsUiState,
|
uiState: StreamsUiState,
|
||||||
|
debridEnabled: Boolean,
|
||||||
|
appendInstantServiceToDefaultName: Boolean,
|
||||||
resumePositionMs: Long?,
|
resumePositionMs: Long?,
|
||||||
resumeProgressFraction: Float?,
|
resumeProgressFraction: Float?,
|
||||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
||||||
|
|
@ -468,6 +474,8 @@ private fun MobileStreamsLayout(
|
||||||
|
|
||||||
StreamList(
|
StreamList(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
debridEnabled = debridEnabled,
|
||||||
|
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
|
||||||
onStreamSelected = onStreamSelected,
|
onStreamSelected = onStreamSelected,
|
||||||
onStreamLongPress = onStreamLongPress,
|
onStreamLongPress = onStreamLongPress,
|
||||||
resumePositionMs = resumePositionMs,
|
resumePositionMs = resumePositionMs,
|
||||||
|
|
@ -761,6 +769,8 @@ private fun FilterChip(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StreamList(
|
internal fun StreamList(
|
||||||
uiState: StreamsUiState,
|
uiState: StreamsUiState,
|
||||||
|
debridEnabled: Boolean,
|
||||||
|
appendInstantServiceToDefaultName: Boolean,
|
||||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
||||||
onStreamLongPress: (StreamItem) -> Unit,
|
onStreamLongPress: (StreamItem) -> Unit,
|
||||||
resumePositionMs: Long?,
|
resumePositionMs: Long?,
|
||||||
|
|
@ -799,6 +809,8 @@ internal fun StreamList(
|
||||||
sectionKey = streamSectionRenderKey(groupIndex = groupIndex, group = group),
|
sectionKey = streamSectionRenderKey(groupIndex = groupIndex, group = group),
|
||||||
group = group,
|
group = group,
|
||||||
showHeader = uiState.selectedFilter == null,
|
showHeader = uiState.selectedFilter == null,
|
||||||
|
debridEnabled = debridEnabled,
|
||||||
|
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
|
||||||
onStreamSelected = onStreamSelected,
|
onStreamSelected = onStreamSelected,
|
||||||
onStreamLongPress = onStreamLongPress,
|
onStreamLongPress = onStreamLongPress,
|
||||||
resumePositionMs = resumePositionMs,
|
resumePositionMs = resumePositionMs,
|
||||||
|
|
@ -822,6 +834,8 @@ private fun LazyListScope.streamSection(
|
||||||
sectionKey: String,
|
sectionKey: String,
|
||||||
group: AddonStreamGroup,
|
group: AddonStreamGroup,
|
||||||
showHeader: Boolean,
|
showHeader: Boolean,
|
||||||
|
debridEnabled: Boolean,
|
||||||
|
appendInstantServiceToDefaultName: Boolean,
|
||||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
||||||
onStreamLongPress: (StreamItem) -> Unit,
|
onStreamLongPress: (StreamItem) -> Unit,
|
||||||
resumePositionMs: Long?,
|
resumePositionMs: Long?,
|
||||||
|
|
@ -865,13 +879,15 @@ private fun LazyListScope.streamSection(
|
||||||
) { _, stream ->
|
) { _, stream ->
|
||||||
StreamCard(
|
StreamCard(
|
||||||
stream = stream,
|
stream = stream,
|
||||||
|
enabled = stream.isSelectableForPlayback(debridEnabled),
|
||||||
|
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (stream.directPlaybackUrl != null || stream.isTorrentStream) {
|
if (stream.isSelectableForPlayback(debridEnabled)) {
|
||||||
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
|
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
if (stream.directPlaybackUrl != null) {
|
if (stream.playableDirectUrl != null) {
|
||||||
onStreamLongPress(stream)
|
onStreamLongPress(stream)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -898,7 +914,7 @@ internal fun streamCardRenderKey(
|
||||||
append(':')
|
append(':')
|
||||||
append(itemIndex)
|
append(itemIndex)
|
||||||
append(':')
|
append(':')
|
||||||
append(stream.url ?: stream.infoHash ?: stream.streamLabel)
|
append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -968,11 +984,12 @@ private fun StreamSourceHeader(
|
||||||
@Composable
|
@Composable
|
||||||
private fun StreamCard(
|
private fun StreamCard(
|
||||||
stream: StreamItem,
|
stream: StreamItem,
|
||||||
|
enabled: Boolean,
|
||||||
|
appendInstantServiceToDefaultName: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream
|
|
||||||
val cardShape = RoundedCornerShape(12.dp)
|
val cardShape = RoundedCornerShape(12.dp)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -987,7 +1004,7 @@ private fun StreamCard(
|
||||||
.clip(cardShape)
|
.clip(cardShape)
|
||||||
.background(Color.White.copy(alpha = 0.05f))
|
.background(Color.White.copy(alpha = 0.05f))
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
enabled = isEnabled,
|
enabled = enabled,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
)
|
)
|
||||||
|
|
@ -995,15 +1012,9 @@ private fun StreamCard(
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
StreamNameWithInstantService(
|
||||||
text = stream.streamLabel,
|
stream = stream,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
letterSpacing = 0.1.sp,
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val subtitle = stream.streamSubtitle
|
val subtitle = stream.streamSubtitle
|
||||||
|
|
@ -1020,13 +1031,68 @@ private fun StreamCard(
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
StreamFileSizeBadge(stream = stream)
|
StreamFileSizeBadge(stream = stream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StreamNameWithInstantService(
|
||||||
|
stream: StreamItem,
|
||||||
|
appendInstantServiceToDefaultName: Boolean,
|
||||||
|
) {
|
||||||
|
val nameStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
)
|
||||||
|
val instantLabel = if (appendInstantServiceToDefaultName) {
|
||||||
|
stream.instantServiceLabel()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val showInstantLabel = instantLabel != null
|
||||||
|
val visibleState = remember(stream.streamLabel) {
|
||||||
|
MutableTransitionState(showInstantLabel)
|
||||||
|
}
|
||||||
|
visibleState.targetState = showInstantLabel
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stream.streamLabel,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
style = nameStyle,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visibleState = visibleState,
|
||||||
|
enter = fadeIn(animationSpec = tween(durationMillis = 260)) +
|
||||||
|
expandHorizontally(
|
||||||
|
animationSpec = tween(durationMillis = 260),
|
||||||
|
expandFrom = Alignment.Start,
|
||||||
|
),
|
||||||
|
exit = fadeOut(animationSpec = tween(durationMillis = 120)) +
|
||||||
|
shrinkHorizontally(
|
||||||
|
animationSpec = tween(durationMillis = 120),
|
||||||
|
shrinkTowards = Alignment.Start,
|
||||||
|
),
|
||||||
|
label = "streamNameInstantService",
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = " ${instantLabel.orEmpty()}",
|
||||||
|
style = nameStyle,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun StreamActionsSheet(
|
private fun StreamActionsSheet(
|
||||||
|
|
@ -1125,6 +1191,15 @@ private fun StreamActionsSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun StreamItem.instantServiceLabel(): String? {
|
||||||
|
val status = debridCacheStatus ?: return null
|
||||||
|
if (status.state != StreamDebridCacheState.CACHED) return null
|
||||||
|
val providerLabel = DebridProviders.shortName(status.providerId)
|
||||||
|
.ifBlank { status.providerName.trim() }
|
||||||
|
.ifBlank { DebridProviders.displayName(status.providerId) }
|
||||||
|
return "- $providerLabel Instant"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StreamFileSizeBadge(stream: StreamItem) {
|
private fun StreamFileSizeBadge(stream: StreamItem) {
|
||||||
val bytes = stream.behaviorHints.videoSize ?: return
|
val bytes = stream.behaviorHints.videoSize ?: return
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ internal fun TabletStreamsLayout(
|
||||||
episodeNumber: Int?,
|
episodeNumber: Int?,
|
||||||
episodeTitle: String?,
|
episodeTitle: String?,
|
||||||
uiState: StreamsUiState,
|
uiState: StreamsUiState,
|
||||||
|
debridEnabled: Boolean,
|
||||||
|
appendInstantServiceToDefaultName: Boolean,
|
||||||
resumePositionMs: Long?,
|
resumePositionMs: Long?,
|
||||||
resumeProgressFraction: Float?,
|
resumeProgressFraction: Float?,
|
||||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
||||||
|
|
@ -199,6 +201,8 @@ internal fun TabletStreamsLayout(
|
||||||
|
|
||||||
StreamList(
|
StreamList(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
debridEnabled = debridEnabled,
|
||||||
|
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
|
||||||
onStreamSelected = onStreamSelected,
|
onStreamSelected = onStreamSelected,
|
||||||
onStreamLongPress = onStreamLongPress,
|
onStreamLongPress = onStreamLongPress,
|
||||||
resumePositionMs = resumePositionMs,
|
resumePositionMs = resumePositionMs,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter(
|
||||||
seasonNumber: Int?,
|
seasonNumber: Int?,
|
||||||
episodeNumber: Int?,
|
episodeNumber: Int?,
|
||||||
todayIsoDate: String,
|
todayIsoDate: String,
|
||||||
|
showUnairedNextUp: Boolean = false,
|
||||||
): WatchingReleasedEpisode? {
|
): WatchingReleasedEpisode? {
|
||||||
val sortedEpisodes = episodes.sortedWith(
|
val sortedEpisodes = episodes.sortedWith(
|
||||||
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
|
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
|
||||||
|
|
@ -76,7 +77,7 @@ fun nextReleasedEpisodeAfter(
|
||||||
candidateSeasonNumber = episode.seasonNumber,
|
candidateSeasonNumber = episode.seasonNumber,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
releasedDate = episode.releasedDate,
|
releasedDate = episode.releasedDate,
|
||||||
showUnairedNextUp = false,
|
showUnairedNextUp = showUnairedNextUp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
|
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
|
||||||
|
|
@ -89,6 +90,7 @@ fun decideSeriesPrimaryAction(
|
||||||
watchedRecords: List<WatchingWatchedRecord>,
|
watchedRecords: List<WatchingWatchedRecord>,
|
||||||
todayIsoDate: String,
|
todayIsoDate: String,
|
||||||
preferFurthestEpisode: Boolean = true,
|
preferFurthestEpisode: Boolean = true,
|
||||||
|
showUnairedNextUp: Boolean = false,
|
||||||
): WatchingSeriesPrimaryAction? {
|
): WatchingSeriesPrimaryAction? {
|
||||||
val resumeRecord = resumeProgressForSeries(
|
val resumeRecord = resumeProgressForSeries(
|
||||||
content = content,
|
content = content,
|
||||||
|
|
@ -112,6 +114,7 @@ fun decideSeriesPrimaryAction(
|
||||||
seasonNumber = latestCompletedEpisode.seasonNumber,
|
seasonNumber = latestCompletedEpisode.seasonNumber,
|
||||||
episodeNumber = latestCompletedEpisode.episodeNumber,
|
episodeNumber = latestCompletedEpisode.episodeNumber,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
|
showUnairedNextUp = showUnairedNextUp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val sorted = episodes
|
val sorted = episodes
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.nuvio.app.features.watchprogress
|
package com.nuvio.app.features.watchprogress
|
||||||
|
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||||
|
import com.nuvio.app.features.cloud.cloudLibraryProviderPosterUrl
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -199,6 +201,7 @@ internal fun nextUpDismissKey(
|
||||||
|
|
||||||
internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
||||||
val normalizedEntry = normalizedCompletion()
|
val normalizedEntry = normalizedCompletion()
|
||||||
|
val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl()
|
||||||
val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent
|
val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent
|
||||||
?.takeIf { durationMs <= 0L && it > 0f }
|
?.takeIf { durationMs <= 0L && it > 0f }
|
||||||
?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) }
|
?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) }
|
||||||
|
|
@ -213,9 +216,9 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
||||||
episodeNumber = normalizedEntry.episodeNumber,
|
episodeNumber = normalizedEntry.episodeNumber,
|
||||||
episodeTitle = normalizedEntry.episodeTitle,
|
episodeTitle = normalizedEntry.episodeTitle,
|
||||||
),
|
),
|
||||||
imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster,
|
imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl,
|
||||||
logo = normalizedEntry.logo,
|
logo = normalizedEntry.logo,
|
||||||
poster = normalizedEntry.poster,
|
poster = normalizedEntry.poster ?: cloudPosterUrl,
|
||||||
background = normalizedEntry.background,
|
background = normalizedEntry.background,
|
||||||
seasonNumber = normalizedEntry.seasonNumber,
|
seasonNumber = normalizedEntry.seasonNumber,
|
||||||
episodeNumber = normalizedEntry.episodeNumber,
|
episodeNumber = normalizedEntry.episodeNumber,
|
||||||
|
|
@ -233,6 +236,16 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun WatchProgressEntry.cloudLibraryPosterFallbackUrl(): String? {
|
||||||
|
if (!contentType.equals(CloudLibraryContentType, ignoreCase = true) &&
|
||||||
|
!parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return cloudLibraryProviderPosterUrl(parentMetaId)
|
||||||
|
?: cloudLibraryProviderPosterUrl(providerAddonId)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
|
internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
|
||||||
nextEpisode: MetaVideo,
|
nextEpisode: MetaVideo,
|
||||||
): ContinueWatchingItem {
|
): ContinueWatchingItem {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
package com.nuvio.app.features.cloud
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridProvider
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviderCapability
|
||||||
|
import com.nuvio.app.features.debrid.DebridServiceCredential
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class CloudLibraryStoreTest {
|
||||||
|
@Test
|
||||||
|
fun `refresh aggregates multiple providers without provider-specific assumptions`() = runBlocking {
|
||||||
|
val firstProvider = cloudProvider(id = "alpha", name = "Alpha")
|
||||||
|
val secondProvider = cloudProvider(id = "beta", name = "Beta")
|
||||||
|
val store = CloudLibraryStore(
|
||||||
|
credentialsProvider = {
|
||||||
|
listOf(
|
||||||
|
DebridServiceCredential(firstProvider, "alpha-token"),
|
||||||
|
DebridServiceCredential(secondProvider, "beta-token"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
providerApis = listOf(
|
||||||
|
FakeCloudProviderApi(
|
||||||
|
provider = firstProvider,
|
||||||
|
items = listOf(cloudItem(firstProvider, "one")),
|
||||||
|
),
|
||||||
|
FakeCloudProviderApi(
|
||||||
|
provider = secondProvider,
|
||||||
|
items = listOf(cloudItem(secondProvider, "two")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = store.refresh()
|
||||||
|
|
||||||
|
assertTrue(state.isLoaded)
|
||||||
|
assertEquals(listOf("alpha", "beta"), state.providers.map { it.providerId })
|
||||||
|
assertEquals(listOf("one", "two"), state.items.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh ignores connected providers without cloud library capability`() = runBlocking {
|
||||||
|
val cloudProvider = cloudProvider(id = "cloud", name = "Cloud")
|
||||||
|
val unsupportedProvider = DebridProvider(
|
||||||
|
id = "plain",
|
||||||
|
displayName = "Plain",
|
||||||
|
shortName = "P",
|
||||||
|
capabilities = setOf(DebridProviderCapability.ClientResolve),
|
||||||
|
)
|
||||||
|
val store = CloudLibraryStore(
|
||||||
|
credentialsProvider = {
|
||||||
|
listOf(
|
||||||
|
DebridServiceCredential(cloudProvider, "cloud-token"),
|
||||||
|
DebridServiceCredential(unsupportedProvider, "plain-token"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
providerApis = listOf(
|
||||||
|
FakeCloudProviderApi(
|
||||||
|
provider = cloudProvider,
|
||||||
|
items = listOf(cloudItem(cloudProvider, "cloud-item")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = store.refresh()
|
||||||
|
|
||||||
|
assertEquals(listOf("cloud"), state.providers.map { it.providerId })
|
||||||
|
assertEquals(listOf("cloud-item"), state.items.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `playback target lookup matches cloud watch progress video id`() {
|
||||||
|
val provider = cloudProvider(id = "torbox", name = "TorBox")
|
||||||
|
val item = CloudLibraryItem(
|
||||||
|
providerId = provider.id,
|
||||||
|
providerName = provider.displayName,
|
||||||
|
id = "29773238",
|
||||||
|
type = CloudLibraryItemType.Torrent,
|
||||||
|
name = "Torrent",
|
||||||
|
files = listOf(
|
||||||
|
CloudLibraryFile(id = "7", name = "sample.mkv", playable = true),
|
||||||
|
CloudLibraryFile(id = "8", name = "movie.mkv", playable = true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val state = CloudLibraryUiState(
|
||||||
|
isLoaded = true,
|
||||||
|
providers = listOf(
|
||||||
|
CloudLibraryProviderState(
|
||||||
|
provider = provider,
|
||||||
|
items = listOf(item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val target = assertNotNull(
|
||||||
|
state.findPlaybackTargetForProgress(
|
||||||
|
contentId = "torbox:Torrent:29773238",
|
||||||
|
videoId = "torbox:Torrent:29773238:8",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(item, target.item)
|
||||||
|
assertEquals("8", target.file.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `playback target lookup falls back to single playable file`() {
|
||||||
|
val provider = cloudProvider(id = "torbox", name = "TorBox")
|
||||||
|
val item = cloudItem(provider, "29773238")
|
||||||
|
val state = CloudLibraryUiState(
|
||||||
|
isLoaded = true,
|
||||||
|
providers = listOf(
|
||||||
|
CloudLibraryProviderState(
|
||||||
|
provider = provider,
|
||||||
|
items = listOf(item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val target = assertNotNull(
|
||||||
|
state.findPlaybackTargetForProgress(
|
||||||
|
contentId = item.stableKey,
|
||||||
|
videoId = item.stableKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(item, target.item)
|
||||||
|
assertEquals(item.playableFiles.single(), target.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve playback reuses already resolved file url`() = runBlocking {
|
||||||
|
val provider = cloudProvider(id = "premiumize", name = "Premiumize")
|
||||||
|
val api = FakeCloudProviderApi(
|
||||||
|
provider = provider,
|
||||||
|
items = emptyList(),
|
||||||
|
)
|
||||||
|
val store = CloudLibraryStore(
|
||||||
|
credentialsProvider = {
|
||||||
|
listOf(DebridServiceCredential(provider, "token"))
|
||||||
|
},
|
||||||
|
providerApis = listOf(api),
|
||||||
|
)
|
||||||
|
val item = cloudItem(provider, "ready")
|
||||||
|
val file = item.playableFiles.single().copy(playbackUrl = "https://cached.example/video.mkv")
|
||||||
|
|
||||||
|
val result = store.resolvePlayback(item = item, file = file)
|
||||||
|
|
||||||
|
assertTrue(result is CloudLibraryPlaybackResult.Success)
|
||||||
|
assertEquals("https://cached.example/video.mkv", result.url)
|
||||||
|
assertEquals(0, api.resolvePlaybackCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolved playback url is remembered in cloud library state`() {
|
||||||
|
val provider = cloudProvider(id = "torbox", name = "TorBox")
|
||||||
|
val item = cloudItem(provider, "29773238")
|
||||||
|
val file = item.playableFiles.single()
|
||||||
|
val state = CloudLibraryUiState(
|
||||||
|
isLoaded = true,
|
||||||
|
providers = listOf(
|
||||||
|
CloudLibraryProviderState(
|
||||||
|
provider = provider,
|
||||||
|
items = listOf(item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val updated = state.withResolvedPlaybackUrl(
|
||||||
|
item = item,
|
||||||
|
file = file,
|
||||||
|
url = "https://resolved.example/movie.mkv",
|
||||||
|
)
|
||||||
|
|
||||||
|
val target = assertNotNull(
|
||||||
|
updated.findPlaybackTargetForProgress(
|
||||||
|
contentId = item.stableKey,
|
||||||
|
videoId = item.playbackVideoId(file),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("https://resolved.example/movie.mkv", target.file.playbackUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `provider poster urls are mapped for cloud services`() {
|
||||||
|
assertEquals(
|
||||||
|
TorboxCloudLibraryPosterUrl,
|
||||||
|
cloudLibraryProviderPosterUrl("torbox:Torrent:29773238"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
PremiumizeCloudLibraryPosterUrl,
|
||||||
|
cloudLibraryProviderPosterUrl("cloud:premiumize"),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
cloudLibraryDisplayArtworkUrl(TorboxCloudLibraryPosterUrl)
|
||||||
|
?.startsWith("data:image/svg+xml;base64,") == true,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
PremiumizeCloudLibraryPosterUrl,
|
||||||
|
cloudLibraryDisplayArtworkUrl(PremiumizeCloudLibraryPosterUrl),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeCloudProviderApi(
|
||||||
|
override val provider: DebridProvider,
|
||||||
|
private val items: List<CloudLibraryItem>,
|
||||||
|
) : CloudLibraryProviderApi {
|
||||||
|
var resolvePlaybackCalls: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override suspend fun listItems(apiKey: String): Result<List<CloudLibraryItem>> =
|
||||||
|
Result.success(items)
|
||||||
|
|
||||||
|
override suspend fun resolvePlayback(
|
||||||
|
apiKey: String,
|
||||||
|
item: CloudLibraryItem,
|
||||||
|
file: CloudLibraryFile,
|
||||||
|
): CloudLibraryPlaybackResult {
|
||||||
|
resolvePlaybackCalls += 1
|
||||||
|
return CloudLibraryPlaybackResult.Success(url = "https://example.test/${item.id}/${file.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cloudProvider(id: String, name: String): DebridProvider =
|
||||||
|
DebridProvider(
|
||||||
|
id = id,
|
||||||
|
displayName = name,
|
||||||
|
shortName = name.take(1),
|
||||||
|
capabilities = setOf(DebridProviderCapability.CloudLibrary),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun cloudItem(provider: DebridProvider, id: String): CloudLibraryItem =
|
||||||
|
CloudLibraryItem(
|
||||||
|
providerId = provider.id,
|
||||||
|
providerName = provider.displayName,
|
||||||
|
id = id,
|
||||||
|
type = CloudLibraryItemType.Torrent,
|
||||||
|
name = id,
|
||||||
|
files = listOf(
|
||||||
|
CloudLibraryFile(
|
||||||
|
id = "file-$id",
|
||||||
|
name = "$id.mkv",
|
||||||
|
playable = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.nuvio.app.features.cloud
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviders
|
||||||
|
import com.nuvio.app.features.debrid.PremiumizeCloudFileDto
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class PremiumizeCloudLibraryProviderApiTest {
|
||||||
|
@Test
|
||||||
|
fun `groups nested files by top-level folder and keeps root files standalone`() {
|
||||||
|
val items = premiumizeCloudItemsFromFiles(
|
||||||
|
files = listOf(
|
||||||
|
PremiumizeCloudFileDto(
|
||||||
|
id = "e01",
|
||||||
|
name = "Show.S01E01.mkv",
|
||||||
|
path = "Show/Season 01/Show.S01E01.mkv",
|
||||||
|
size = 1_000,
|
||||||
|
mimeType = "video/x-matroska",
|
||||||
|
link = "https://pm/e01",
|
||||||
|
),
|
||||||
|
PremiumizeCloudFileDto(
|
||||||
|
id = "e02",
|
||||||
|
name = "Show.S01E02.mkv",
|
||||||
|
path = "Show/Season 01/Show.S01E02.mkv",
|
||||||
|
size = 2_000,
|
||||||
|
mimeType = "video/x-matroska",
|
||||||
|
link = "https://pm/e02",
|
||||||
|
),
|
||||||
|
PremiumizeCloudFileDto(
|
||||||
|
id = "movie",
|
||||||
|
name = "Movie.mp4",
|
||||||
|
path = "Movie.mp4",
|
||||||
|
size = 3_000,
|
||||||
|
mimeType = "video/mp4",
|
||||||
|
link = "https://pm/movie",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
providerId = DebridProviders.PREMIUMIZE_ID,
|
||||||
|
providerName = "Premiumize",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("Movie.mp4", "Show"), items.map { it.name })
|
||||||
|
assertEquals(CloudLibraryItemType.File, items.first().type)
|
||||||
|
assertEquals(listOf("Show.S01E01.mkv", "Show.S01E02.mkv"), items[1].files.map { it.name })
|
||||||
|
assertEquals("https://pm/e01", items[1].files.first().playbackUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `marks non video and missing fields as non playable without dropping valid files`() {
|
||||||
|
val items = premiumizeCloudItemsFromFiles(
|
||||||
|
files = listOf(
|
||||||
|
PremiumizeCloudFileDto(id = "notes", name = "notes.txt", path = "Pack/notes.txt", size = 100),
|
||||||
|
PremiumizeCloudFileDto(id = "video", name = "video.avi", path = "Pack/video.avi", size = 200),
|
||||||
|
PremiumizeCloudFileDto(id = "missing", name = null, path = null, size = 300),
|
||||||
|
),
|
||||||
|
providerId = DebridProviders.PREMIUMIZE_ID,
|
||||||
|
providerName = "Premiumize",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, items.size)
|
||||||
|
assertEquals(2, items.single().files.size)
|
||||||
|
assertFalse(items.single().files.first { it.name == "notes.txt" }.playable)
|
||||||
|
assertTrue(items.single().files.first { it.name == "video.avi" }.playable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
package com.nuvio.app.features.cloud
|
||||||
|
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviders
|
||||||
|
import com.nuvio.app.features.debrid.TorboxCloudFileDto
|
||||||
|
import com.nuvio.app.features.debrid.TorboxCloudItemDto
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class TorboxCloudLibraryProviderApiTest {
|
||||||
|
@Test
|
||||||
|
fun `maps torrent dto with status progress size and playable files`() {
|
||||||
|
val item = TorboxCloudItemDto(
|
||||||
|
id = JsonPrimitive(42),
|
||||||
|
name = "Movie Pack",
|
||||||
|
status = "completed",
|
||||||
|
progress = 75.0,
|
||||||
|
size = 1_024L,
|
||||||
|
files = listOf(
|
||||||
|
TorboxCloudFileDto(
|
||||||
|
id = JsonPrimitive(8),
|
||||||
|
name = "movie.mkv",
|
||||||
|
mimeType = "video/x-matroska",
|
||||||
|
size = 512L,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toCloudLibraryItem(
|
||||||
|
providerId = DebridProviders.Torbox.id,
|
||||||
|
providerName = DebridProviders.Torbox.displayName,
|
||||||
|
type = CloudLibraryItemType.Torrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(item)
|
||||||
|
assertEquals("42", item.id)
|
||||||
|
assertEquals(CloudLibraryItemType.Torrent, item.type)
|
||||||
|
assertEquals("completed", item.status)
|
||||||
|
assertEquals(0.75f, item.progressFraction)
|
||||||
|
assertEquals(1_024L, item.sizeBytes)
|
||||||
|
assertEquals(listOf("8"), item.files.map { it.id })
|
||||||
|
assertTrue(item.files.single().playable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mapping falls back to hash and file absolute path when friendly fields are missing`() {
|
||||||
|
val item = TorboxCloudItemDto(
|
||||||
|
hash = "abc123",
|
||||||
|
files = listOf(
|
||||||
|
TorboxCloudFileDto(
|
||||||
|
id = JsonPrimitive("file-1"),
|
||||||
|
absolutePath = "/downloads/show.mp4",
|
||||||
|
size = 256L,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toCloudLibraryItem(
|
||||||
|
providerId = "torbox",
|
||||||
|
providerName = "Torbox",
|
||||||
|
type = CloudLibraryItemType.Usenet,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(item)
|
||||||
|
assertEquals("abc123", item.id)
|
||||||
|
assertEquals("abc123", item.name)
|
||||||
|
assertEquals("show.mp4", item.files.single().name)
|
||||||
|
assertTrue(item.files.single().playable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mapping prefers absolute path basename when file name repeats pack name`() {
|
||||||
|
val item = TorboxCloudItemDto(
|
||||||
|
id = JsonPrimitive(44),
|
||||||
|
name = "The Rookie S01",
|
||||||
|
files = listOf(
|
||||||
|
TorboxCloudFileDto(
|
||||||
|
id = JsonPrimitive(1),
|
||||||
|
name = "The Rookie S01",
|
||||||
|
absolutePath = "/The Rookie S01/The.Rookie.S01E01.1080p.WEB-DL.mkv",
|
||||||
|
mimeType = "video/x-matroska",
|
||||||
|
),
|
||||||
|
TorboxCloudFileDto(
|
||||||
|
id = JsonPrimitive(2),
|
||||||
|
shortName = "The Rookie S01",
|
||||||
|
absolutePath = "/The Rookie S01/The.Rookie.S01E02.1080p.WEB-DL.mkv",
|
||||||
|
mimeType = "video/x-matroska",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toCloudLibraryItem(
|
||||||
|
providerId = "torbox",
|
||||||
|
providerName = "Torbox",
|
||||||
|
type = CloudLibraryItemType.Torrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(item)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"The.Rookie.S01E01.1080p.WEB-DL.mkv",
|
||||||
|
"The.Rookie.S01E02.1080p.WEB-DL.mkv",
|
||||||
|
),
|
||||||
|
item.playableFiles.map { it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mapping prefers short name when Torbox file name is a relative pack path`() {
|
||||||
|
val item = TorboxCloudItemDto(
|
||||||
|
id = JsonPrimitive(29556645),
|
||||||
|
name = "From.The.Earth.To.The.Moon.1998.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]",
|
||||||
|
files = listOf(
|
||||||
|
TorboxCloudFileDto(
|
||||||
|
id = JsonPrimitive(1),
|
||||||
|
name = "From.The.Earth.To.The.Moon.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]/From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv",
|
||||||
|
shortName = "From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv",
|
||||||
|
absolutePath = "/completed/2c229180e129280a36ba7f3a22e2f5135a02a766/From.The.Earth.To.The.Moon.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]/From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv",
|
||||||
|
mimeType = "video/x-matroska",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toCloudLibraryItem(
|
||||||
|
providerId = "torbox",
|
||||||
|
providerName = "Torbox",
|
||||||
|
type = CloudLibraryItemType.Torrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(item)
|
||||||
|
assertEquals(
|
||||||
|
"From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv",
|
||||||
|
item.playableFiles.single().name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mapping handles missing item ids and empty file lists`() {
|
||||||
|
assertNull(
|
||||||
|
TorboxCloudItemDto(name = "No ID").toCloudLibraryItem(
|
||||||
|
providerId = "torbox",
|
||||||
|
providerName = "Torbox",
|
||||||
|
type = CloudLibraryItemType.WebDownload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val item = TorboxCloudItemDto(
|
||||||
|
id = JsonPrimitive(7),
|
||||||
|
name = "Empty",
|
||||||
|
files = emptyList(),
|
||||||
|
).toCloudLibraryItem(
|
||||||
|
providerId = "torbox",
|
||||||
|
providerName = "Torbox",
|
||||||
|
type = CloudLibraryItemType.WebDownload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(item)
|
||||||
|
assertTrue(item.files.isEmpty())
|
||||||
|
assertTrue(item.playableFiles.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mapping keeps non-playable files but excludes them from playable files`() {
|
||||||
|
val item = TorboxCloudItemDto(
|
||||||
|
id = JsonPrimitive(9),
|
||||||
|
name = "Mixed",
|
||||||
|
files = listOf(
|
||||||
|
TorboxCloudFileDto(
|
||||||
|
id = JsonPrimitive(1),
|
||||||
|
name = "readme.txt",
|
||||||
|
mimeType = "text/plain",
|
||||||
|
),
|
||||||
|
TorboxCloudFileDto(
|
||||||
|
name = "missing-id.mkv",
|
||||||
|
mimeType = "video/x-matroska",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toCloudLibraryItem(
|
||||||
|
providerId = "torbox",
|
||||||
|
providerName = "Torbox",
|
||||||
|
type = CloudLibraryItemType.Torrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(item)
|
||||||
|
assertEquals(2, item.files.size)
|
||||||
|
assertFalse(item.files[0].playable)
|
||||||
|
assertFalse(item.files[1].playable)
|
||||||
|
assertTrue(item.playableFiles.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `request download parameter names match Torbox item type`() {
|
||||||
|
assertEquals("torrent_id", torboxRequestIdParameterName(CloudLibraryItemType.Torrent))
|
||||||
|
assertEquals("usenet_id", torboxRequestIdParameterName(CloudLibraryItemType.Usenet))
|
||||||
|
assertEquals("web_id", torboxRequestIdParameterName(CloudLibraryItemType.WebDownload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolve
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class DebridFileSelectorTest {
|
||||||
|
@Test
|
||||||
|
fun `Torbox selector prefers exact file id`() {
|
||||||
|
val files = listOf(
|
||||||
|
TorboxTorrentFileDto(id = 1, name = "small.mkv", size = 1),
|
||||||
|
TorboxTorrentFileDto(id = 8, name = "target.mkv", size = 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = TorboxFileSelector().selectFile(
|
||||||
|
files = files,
|
||||||
|
resolve = resolve(fileIdx = 8),
|
||||||
|
season = null,
|
||||||
|
episode = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(8, selected?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Torbox selector prefers filename match before provider file id`() {
|
||||||
|
val files = listOf(
|
||||||
|
TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1),
|
||||||
|
TorboxTorrentFileDto(
|
||||||
|
id = 85,
|
||||||
|
name = "The Office US S01-S09/The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv",
|
||||||
|
size = 5_303_936_915,
|
||||||
|
),
|
||||||
|
TorboxTorrentFileDto(
|
||||||
|
id = 1,
|
||||||
|
name = "The Office US S01-S09/The.Office.US.S08E13.Jury.Duty.1080p.BluRay.Remux.mkv",
|
||||||
|
size = 5_859_312_140,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = TorboxFileSelector().selectFile(
|
||||||
|
files = files,
|
||||||
|
resolve = resolve(
|
||||||
|
fileIdx = 1,
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
filename = "The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv",
|
||||||
|
),
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(85, selected?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Torbox selector treats fileIdx as source list index before provider file id`() {
|
||||||
|
val files = listOf(
|
||||||
|
TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1),
|
||||||
|
TorboxTorrentFileDto(id = 85, name = "Show.S01E01.mkv", size = 500),
|
||||||
|
TorboxTorrentFileDto(id = 1, name = "Show.S08E13.mkv", size = 900),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = TorboxFileSelector().selectFile(
|
||||||
|
files = files,
|
||||||
|
resolve = resolve(fileIdx = 1),
|
||||||
|
season = null,
|
||||||
|
episode = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(85, selected?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Torbox selector uses episode pattern before broad title`() {
|
||||||
|
val files = listOf(
|
||||||
|
TorboxTorrentFileDto(id = 1, name = "The.Office.US.S08E13.Jury.Duty.mkv", size = 900),
|
||||||
|
TorboxTorrentFileDto(id = 85, name = "The.Office.US.S01E01.Pilot.mkv", size = 500),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = TorboxFileSelector().selectFile(
|
||||||
|
files = files,
|
||||||
|
resolve = resolve(
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
title = "The Office",
|
||||||
|
),
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(85, selected?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Torbox selector falls back to largest playable video`() {
|
||||||
|
val files = listOf(
|
||||||
|
TorboxTorrentFileDto(id = 1, name = "sample.txt", size = 999),
|
||||||
|
TorboxTorrentFileDto(id = 2, name = "episode.mkv", size = 200),
|
||||||
|
TorboxTorrentFileDto(id = 3, name = "episode-1080p.mp4", size = 500),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = TorboxFileSelector().selectFile(
|
||||||
|
files = files,
|
||||||
|
resolve = resolve(),
|
||||||
|
season = null,
|
||||||
|
episode = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(3, selected?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Real-Debrid selector matches episode pattern before largest file`() {
|
||||||
|
val files = listOf(
|
||||||
|
RealDebridTorrentFileDto(id = 1, path = "/Show.S01E01.mkv", bytes = 1_000),
|
||||||
|
RealDebridTorrentFileDto(id = 2, path = "/Show.S01E02.mkv", bytes = 2_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = RealDebridFileSelector().selectFile(
|
||||||
|
files = files,
|
||||||
|
resolve = resolve(season = 1, episode = 1),
|
||||||
|
season = null,
|
||||||
|
episode = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, selected?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Premiumize direct download selector ignores non-video and matches episode`() {
|
||||||
|
val files = listOf(
|
||||||
|
PremiumizeDirectDownloadFileDto(path = "Show/Readme.txt", size = 9_000, link = "https://pm/readme"),
|
||||||
|
PremiumizeDirectDownloadFileDto(path = "Show/Show.S01E02.mkv", size = 2_000, link = "https://pm/e02"),
|
||||||
|
PremiumizeDirectDownloadFileDto(path = "Show/Show.S01E01.mkv", size = 1_000, link = "https://pm/e01"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = PremiumizeDirectDownloadFileSelector().selectFile(
|
||||||
|
files = files,
|
||||||
|
resolve = resolve(season = 1, episode = 1),
|
||||||
|
season = null,
|
||||||
|
episode = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Show/Show.S01E01.mkv", selected?.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Premiumize direct download selector falls back to largest playable file`() {
|
||||||
|
val files = listOf(
|
||||||
|
PremiumizeDirectDownloadFileDto(path = "small.mp4", size = 1_000, link = "https://pm/small"),
|
||||||
|
PremiumizeDirectDownloadFileDto(path = "large.mkv", size = 3_000, link = "https://pm/large"),
|
||||||
|
PremiumizeDirectDownloadFileDto(path = "large-without-link.mkv", size = 9_000, link = null),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = PremiumizeDirectDownloadFileSelector().selectFile(
|
||||||
|
files = files,
|
||||||
|
resolve = resolve(),
|
||||||
|
season = null,
|
||||||
|
episode = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("large.mkv", selected?.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolve(
|
||||||
|
fileIdx: Int? = null,
|
||||||
|
season: Int? = null,
|
||||||
|
episode: Int? = null,
|
||||||
|
filename: String? = null,
|
||||||
|
title: String? = null,
|
||||||
|
): StreamClientResolve =
|
||||||
|
StreamClientResolve(
|
||||||
|
type = "debrid",
|
||||||
|
service = DebridProviders.TORBOX_ID,
|
||||||
|
isCached = true,
|
||||||
|
infoHash = "hash",
|
||||||
|
fileIdx = fileIdx,
|
||||||
|
filename = filename,
|
||||||
|
title = title,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class DebridProviderTest {
|
||||||
|
@Test
|
||||||
|
fun `torbox exposes local addon capabilities`() {
|
||||||
|
assertTrue(DebridProviders.Torbox.authMethod == DebridProviderAuthMethod.DeviceCode)
|
||||||
|
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve))
|
||||||
|
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck))
|
||||||
|
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve))
|
||||||
|
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.CloudLibrary))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `premiumize exposes oauth and cloud service capabilities`() {
|
||||||
|
assertTrue(DebridProviders.Premiumize.visibleInUi)
|
||||||
|
assertTrue(DebridProviders.Premiumize.authMethod == DebridProviderAuthMethod.DeviceCode)
|
||||||
|
assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.ClientResolve))
|
||||||
|
assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.LocalTorrentCacheCheck))
|
||||||
|
assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.LocalTorrentResolve))
|
||||||
|
assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.CloudLibrary))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `real debrid stays hidden from local addon capability paths`() {
|
||||||
|
assertTrue(DebridProviders.RealDebrid.authMethod == DebridProviderAuthMethod.ApiKey)
|
||||||
|
assertFalse(DebridProviders.RealDebrid.visibleInUi)
|
||||||
|
assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve))
|
||||||
|
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck))
|
||||||
|
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentResolve))
|
||||||
|
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.CloudLibrary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class DebridSettingsTest {
|
||||||
|
@Test
|
||||||
|
fun `normalizes provider ids when reading api keys`() {
|
||||||
|
val settings = DebridSettings(
|
||||||
|
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "tb_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("tb_key", settings.apiKeyFor("TORBOX"))
|
||||||
|
assertEquals("tb_key", settings.torboxApiKey)
|
||||||
|
assertEquals("", settings.realDebridApiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `configured services are driven by visible registered providers`() {
|
||||||
|
val settings = DebridSettings(
|
||||||
|
providerApiKeys = mapOf(
|
||||||
|
DebridProviders.TORBOX_ID to "tb_key",
|
||||||
|
DebridProviders.PREMIUMIZE_ID to "pm_key",
|
||||||
|
DebridProviders.REAL_DEBRID_ID to "rd_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val services = DebridProviders.configuredServices(settings)
|
||||||
|
|
||||||
|
assertEquals(listOf(DebridProviders.TORBOX_ID, DebridProviders.PREMIUMIZE_ID), services.map { it.provider.id })
|
||||||
|
assertEquals(listOf("tb_key", "pm_key"), services.map { it.apiKey })
|
||||||
|
assertTrue(settings.hasAnyApiKey)
|
||||||
|
assertFalse(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `preferred resolver uses saved provider when connected and falls back otherwise`() {
|
||||||
|
val preferred = DebridSettings(
|
||||||
|
enabled = true,
|
||||||
|
providerApiKeys = mapOf(
|
||||||
|
DebridProviders.TORBOX_ID to "tb_key",
|
||||||
|
DebridProviders.PREMIUMIZE_ID to "pm_key",
|
||||||
|
),
|
||||||
|
preferredResolverProviderId = DebridProviders.PREMIUMIZE_ID,
|
||||||
|
)
|
||||||
|
val fallback = preferred.copy(preferredResolverProviderId = DebridProviders.REAL_DEBRID_ID)
|
||||||
|
|
||||||
|
assertEquals(DebridProviders.PREMIUMIZE_ID, preferred.activeResolverProviderId)
|
||||||
|
assertEquals(DebridProviders.TORBOX_ID, fallback.activeResolverProviderId)
|
||||||
|
assertTrue(preferred.canResolvePlayableLinks)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cloud library and link resolving capabilities are independent`() {
|
||||||
|
val settings = DebridSettings(
|
||||||
|
enabled = false,
|
||||||
|
cloudLibraryEnabled = true,
|
||||||
|
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "tb_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(settings.canUseCloudLibrary)
|
||||||
|
assertFalse(settings.canResolvePlayableLinks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||||
|
import com.nuvio.app.features.streams.StreamBehaviorHints
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolve
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolveParsed
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolveRaw
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolveStream
|
||||||
|
import com.nuvio.app.features.streams.StreamDebridCacheState
|
||||||
|
import com.nuvio.app.features.streams.StreamDebridCacheStatus
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContains
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
|
||||||
|
class DebridStreamPresentationTest {
|
||||||
|
@Test
|
||||||
|
fun `formats cached addon torrent streams with custom templates`() {
|
||||||
|
val stream = localTorboxStream(
|
||||||
|
filename = "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv",
|
||||||
|
size = 8_589_934_592,
|
||||||
|
)
|
||||||
|
|
||||||
|
val formatted = DebridStreamFormatter().format(
|
||||||
|
stream = stream,
|
||||||
|
settings = DebridSettings(
|
||||||
|
enabled = true,
|
||||||
|
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||||
|
streamNameTemplate = "{stream.resolution} {service.shortName} {service.cached::istrue[\"Ready\"||\"Not Ready\"]}",
|
||||||
|
streamDescriptionTemplate = "{stream.quality} {stream.encode}\n{stream.size::bytes}\n{stream.filename}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("2160p TB Ready", formatted.name)
|
||||||
|
val description = formatted.description.orEmpty()
|
||||||
|
assertContains(description, "WEB-DL HEVC")
|
||||||
|
assertContains(description, "8 GB")
|
||||||
|
assertContains(description, "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default formatter replaces addon source labels for managed streams`() {
|
||||||
|
val stream = premiumizeDirectStream(
|
||||||
|
name = "[P2P] Torrentio 2160p - PM Instant",
|
||||||
|
filename = "The.Boys.S03E01.Payback.2160p.WEB-DL.H265.mkv",
|
||||||
|
size = 12_000_000_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
val presented = DebridStreamPresentation.apply(
|
||||||
|
groups = listOf(
|
||||||
|
AddonStreamGroup(
|
||||||
|
addonName = "Torrentio",
|
||||||
|
addonId = "addon:torrentio",
|
||||||
|
streams = listOf(stream),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
settings = DebridSettings(
|
||||||
|
enabled = true,
|
||||||
|
providerApiKeys = mapOf(DebridProviders.PREMIUMIZE_ID to "pm_key"),
|
||||||
|
),
|
||||||
|
).single().streams.single()
|
||||||
|
|
||||||
|
val name = presented.name.orEmpty()
|
||||||
|
assertEquals("2160p PM Instant", name)
|
||||||
|
assertFalse(name.contains("P2P", ignoreCase = true))
|
||||||
|
assertFalse(name.contains("torrent", ignoreCase = true))
|
||||||
|
assertFalse(name.contains("Torrentio", ignoreCase = true))
|
||||||
|
assertFalse(name.contains("Comet", ignoreCase = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applies debrid sort filters and limits without removing normal urls`() {
|
||||||
|
val low = localTorboxStream(
|
||||||
|
name = "Low",
|
||||||
|
filename = "Movie.720p.BluRay.x264-GRP.mkv",
|
||||||
|
size = 4_000_000_000,
|
||||||
|
)
|
||||||
|
val large = localTorboxStream(
|
||||||
|
name = "Large",
|
||||||
|
filename = "Movie.2160p.BluRay.REMUX.HEVC-GRP.mkv",
|
||||||
|
size = 40_000_000_000,
|
||||||
|
)
|
||||||
|
val mid = localTorboxStream(
|
||||||
|
name = "Mid",
|
||||||
|
filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv",
|
||||||
|
size = 10_000_000_000,
|
||||||
|
)
|
||||||
|
val urlStream = StreamItem(
|
||||||
|
name = "Resolved addon URL",
|
||||||
|
url = "https://example.test/video.m3u8",
|
||||||
|
addonName = "Addon",
|
||||||
|
addonId = "addon:test",
|
||||||
|
)
|
||||||
|
|
||||||
|
val group = AddonStreamGroup(
|
||||||
|
addonName = "Addon",
|
||||||
|
addonId = "addon:test",
|
||||||
|
streams = listOf(low, large, mid, urlStream),
|
||||||
|
)
|
||||||
|
val presented = DebridStreamPresentation.apply(
|
||||||
|
groups = listOf(group),
|
||||||
|
settings = DebridSettings(
|
||||||
|
enabled = true,
|
||||||
|
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||||
|
streamMaxResults = 2,
|
||||||
|
streamSortMode = DebridStreamSortMode.QUALITY_DESC,
|
||||||
|
streamMinimumQuality = DebridStreamMinimumQuality.P1080,
|
||||||
|
streamCodecFilter = DebridStreamCodecFilter.HEVC,
|
||||||
|
),
|
||||||
|
).single().streams
|
||||||
|
|
||||||
|
assertEquals(listOf("2160p TB Instant", "1080p TB Instant", "Resolved addon URL"), presented.map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hides addon torrent streams that are not cached`() {
|
||||||
|
val cached = localTorboxStream(
|
||||||
|
name = "Cached",
|
||||||
|
filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv",
|
||||||
|
size = 10_000_000_000,
|
||||||
|
)
|
||||||
|
val uncached = localTorboxStream(
|
||||||
|
name = "Uncached",
|
||||||
|
filename = "Movie.2160p.WEB-DL.HEVC-GRP.mkv",
|
||||||
|
size = 20_000_000_000,
|
||||||
|
cacheState = StreamDebridCacheState.NOT_CACHED,
|
||||||
|
)
|
||||||
|
|
||||||
|
val presented = DebridStreamPresentation.apply(
|
||||||
|
groups = listOf(
|
||||||
|
AddonStreamGroup(
|
||||||
|
addonName = "Addon",
|
||||||
|
addonId = "addon:test",
|
||||||
|
streams = listOf(cached, uncached),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
settings = DebridSettings(
|
||||||
|
enabled = true,
|
||||||
|
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||||
|
),
|
||||||
|
).single().streams
|
||||||
|
|
||||||
|
assertEquals(listOf("1080p TB Instant"), presented.map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `leaves cloud-service results untouched when link resolving is off`() {
|
||||||
|
val uncached = localTorboxStream(
|
||||||
|
name = "Uncached",
|
||||||
|
filename = "Movie.2160p.WEB-DL.HEVC-GRP.mkv",
|
||||||
|
size = 20_000_000_000,
|
||||||
|
cacheState = StreamDebridCacheState.NOT_CACHED,
|
||||||
|
)
|
||||||
|
|
||||||
|
val presented = DebridStreamPresentation.apply(
|
||||||
|
groups = listOf(
|
||||||
|
AddonStreamGroup(
|
||||||
|
addonName = "Addon",
|
||||||
|
addonId = "addon:test",
|
||||||
|
streams = listOf(uncached),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
settings = DebridSettings(
|
||||||
|
enabled = false,
|
||||||
|
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||||
|
),
|
||||||
|
).single().streams
|
||||||
|
|
||||||
|
assertEquals(listOf("Uncached"), presented.map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localTorboxStream(
|
||||||
|
name: String = "Torrent",
|
||||||
|
filename: String,
|
||||||
|
size: Long,
|
||||||
|
cacheState: StreamDebridCacheState = StreamDebridCacheState.CACHED,
|
||||||
|
): StreamItem =
|
||||||
|
StreamItem(
|
||||||
|
name = name,
|
||||||
|
infoHash = "abcdef1234567890abcdef1234567890abcdef12$size".take(40),
|
||||||
|
addonName = "Addon",
|
||||||
|
addonId = "addon:test",
|
||||||
|
behaviorHints = StreamBehaviorHints(
|
||||||
|
filename = filename,
|
||||||
|
videoSize = size,
|
||||||
|
),
|
||||||
|
debridCacheStatus = StreamDebridCacheStatus(
|
||||||
|
providerId = DebridProviders.TORBOX_ID,
|
||||||
|
providerName = DebridProviders.Torbox.displayName,
|
||||||
|
state = cacheState,
|
||||||
|
cachedName = filename,
|
||||||
|
cachedSize = size,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun premiumizeDirectStream(
|
||||||
|
name: String,
|
||||||
|
filename: String,
|
||||||
|
size: Long,
|
||||||
|
): StreamItem =
|
||||||
|
StreamItem(
|
||||||
|
name = name,
|
||||||
|
addonName = "Torrentio",
|
||||||
|
addonId = "addon:torrentio",
|
||||||
|
clientResolve = StreamClientResolve(
|
||||||
|
type = "debrid",
|
||||||
|
service = DebridProviders.PREMIUMIZE_ID,
|
||||||
|
filename = filename,
|
||||||
|
isCached = true,
|
||||||
|
stream = StreamClientResolveStream(
|
||||||
|
raw = StreamClientResolveRaw(
|
||||||
|
filename = filename,
|
||||||
|
size = size,
|
||||||
|
parsed = StreamClientResolveParsed(
|
||||||
|
resolution = "2160p",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.features.player.PlayerSettingsUiState
|
||||||
|
import com.nuvio.app.features.streams.StreamAutoPlayMode
|
||||||
|
import com.nuvio.app.features.streams.StreamClientResolve
|
||||||
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class DirectDebridStreamPreparerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prioritizes autoplay direct debrid match before display order`() {
|
||||||
|
val first = directDebridStream(name = "1080p", infoHash = "hash-1")
|
||||||
|
val autoPlayMatch = directDebridStream(name = "2160p WEB", infoHash = "hash-2")
|
||||||
|
val remaining = directDebridStream(name = "720p", infoHash = "hash-3")
|
||||||
|
|
||||||
|
val selected = DirectDebridStreamPreparer.prioritizeCandidates(
|
||||||
|
streams = listOf(first, autoPlayMatch, remaining),
|
||||||
|
limit = 2,
|
||||||
|
playerSettings = PlayerSettingsUiState(
|
||||||
|
streamAutoPlayMode = StreamAutoPlayMode.REGEX_MATCH,
|
||||||
|
streamAutoPlayRegex = "2160p",
|
||||||
|
),
|
||||||
|
installedAddonNames = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(autoPlayMatch, first), selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips already resolved and duplicate direct debrid candidates`() {
|
||||||
|
val unresolved = directDebridStream(name = "1080p", infoHash = "hash-1")
|
||||||
|
val duplicate = directDebridStream(name = "1080p Duplicate", infoHash = "HASH-1")
|
||||||
|
val alreadyResolved = directDebridStream(
|
||||||
|
name = "2160p",
|
||||||
|
infoHash = "hash-2",
|
||||||
|
url = "https://example.com/ready.mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = DirectDebridStreamPreparer.prioritizeCandidates(
|
||||||
|
streams = listOf(unresolved, duplicate, alreadyResolved),
|
||||||
|
limit = 5,
|
||||||
|
playerSettings = PlayerSettingsUiState(),
|
||||||
|
installedAddonNames = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(unresolved), selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun directDebridStream(
|
||||||
|
name: String,
|
||||||
|
infoHash: String,
|
||||||
|
url: String? = null,
|
||||||
|
): StreamItem =
|
||||||
|
StreamItem(
|
||||||
|
name = name,
|
||||||
|
url = url,
|
||||||
|
addonName = "Addon",
|
||||||
|
addonId = "addon:test",
|
||||||
|
clientResolve = StreamClientResolve(
|
||||||
|
type = "debrid",
|
||||||
|
service = DebridProviders.TORBOX_ID,
|
||||||
|
isCached = true,
|
||||||
|
infoHash = infoHash,
|
||||||
|
fileIdx = 1,
|
||||||
|
filename = "video.mkv",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class PremiumizeDeviceAuthTest {
|
||||||
|
@Test
|
||||||
|
fun `maps pending and slow down oauth states to pending`() {
|
||||||
|
assertEquals(
|
||||||
|
DebridDeviceAuthorizationTokenResult.Pending,
|
||||||
|
premiumizeDeviceAuthorizationTokenResult(tokenError("authorization_pending")),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DebridDeviceAuthorizationTokenResult.Pending,
|
||||||
|
premiumizeDeviceAuthorizationTokenResult(tokenError("slow_down")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `maps success expired denied and invalid oauth states`() {
|
||||||
|
assertTrue(
|
||||||
|
premiumizeDeviceAuthorizationTokenResult(
|
||||||
|
DebridApiResponse(
|
||||||
|
status = 200,
|
||||||
|
body = PremiumizeDeviceTokenDto(accessToken = "pm-token", tokenType = "Bearer"),
|
||||||
|
rawBody = "",
|
||||||
|
),
|
||||||
|
) is DebridDeviceAuthorizationTokenResult.Authorized,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DebridDeviceAuthorizationTokenResult.Expired,
|
||||||
|
premiumizeDeviceAuthorizationTokenResult(tokenError("invalid_grant")),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
premiumizeDeviceAuthorizationTokenResult(tokenError("access_denied")) is
|
||||||
|
DebridDeviceAuthorizationTokenResult.Failed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing Premiumize client id fails before device flow starts`() = runBlocking {
|
||||||
|
val api = PremiumizeDebridProviderApi(clientIdProvider = { "" })
|
||||||
|
|
||||||
|
val failed = try {
|
||||||
|
api.startDeviceAuthorization("Nuvio")
|
||||||
|
false
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokenError(error: String): DebridApiResponse<PremiumizeDeviceTokenDto> =
|
||||||
|
DebridApiResponse(
|
||||||
|
status = 400,
|
||||||
|
body = PremiumizeDeviceTokenDto(error = error, errorDescription = error),
|
||||||
|
rawBody = """{"error":"$error"}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class TorboxDeviceAuthTest {
|
||||||
|
@Test
|
||||||
|
fun `maps unused device code response to pending`() {
|
||||||
|
val response = DebridApiResponse(
|
||||||
|
status = 400,
|
||||||
|
body = TorboxEnvelopeDto<TorboxDeviceTokenDto>(
|
||||||
|
success = false,
|
||||||
|
detail = "This device code has not been used yet. Please wait for the user to scan the code.",
|
||||||
|
),
|
||||||
|
rawBody = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DebridDeviceAuthorizationTokenResult.Pending,
|
||||||
|
torboxDeviceAuthorizationTokenResult(response),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `maps authorized and expired Torbox device states`() {
|
||||||
|
assertTrue(
|
||||||
|
torboxDeviceAuthorizationTokenResult(
|
||||||
|
DebridApiResponse(
|
||||||
|
status = 200,
|
||||||
|
body = TorboxEnvelopeDto(
|
||||||
|
success = true,
|
||||||
|
data = TorboxDeviceTokenDto(accessToken = "tb-token", tokenType = "Bearer"),
|
||||||
|
),
|
||||||
|
rawBody = "",
|
||||||
|
),
|
||||||
|
) is DebridDeviceAuthorizationTokenResult.Authorized,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DebridDeviceAuthorizationTokenResult.Expired,
|
||||||
|
torboxDeviceAuthorizationTokenResult(
|
||||||
|
DebridApiResponse(
|
||||||
|
status = 410,
|
||||||
|
body = TorboxEnvelopeDto(success = false, detail = "Device code expired."),
|
||||||
|
rawBody = "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.details
|
||||||
|
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
|
|
@ -89,6 +90,46 @@ class SeriesPlaybackResolverTest {
|
||||||
assertEquals("show:1:3", action.videoId)
|
assertEquals("show:1:3", action.videoId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seriesPrimaryAction_uses_explicit_content_when_meta_id_is_alias() {
|
||||||
|
val meta = MetaDetails(
|
||||||
|
id = "tt1234567",
|
||||||
|
type = "series",
|
||||||
|
name = "Show",
|
||||||
|
videos = listOf(
|
||||||
|
MetaVideo(id = "s4e14", title = "Episode 14", season = 4, episode = 14, released = "2026-03-01"),
|
||||||
|
MetaVideo(id = "s4e15", title = "Episode 15", season = 4, episode = 15, released = "2026-03-08"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val action = meta.seriesPrimaryAction(
|
||||||
|
content = WatchingContentRef(type = "series", id = "tmdb:98765"),
|
||||||
|
entries = listOf(
|
||||||
|
WatchProgressEntry(
|
||||||
|
contentType = "series",
|
||||||
|
parentMetaId = "tmdb:98765",
|
||||||
|
parentMetaType = "series",
|
||||||
|
videoId = "tmdb:98765:4:14",
|
||||||
|
title = "Show",
|
||||||
|
seasonNumber = 4,
|
||||||
|
episodeNumber = 14,
|
||||||
|
lastPositionMs = 10_000L,
|
||||||
|
durationMs = 10_000L,
|
||||||
|
lastUpdatedEpochMs = 100L,
|
||||||
|
isCompleted = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
watchedItems = emptyList(),
|
||||||
|
todayIsoDate = "2026-03-30",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(action)
|
||||||
|
assertEquals("Up Next • S4E15", action.label)
|
||||||
|
assertEquals("tmdb:98765:4:15", action.videoId)
|
||||||
|
assertEquals(4, action.seasonNumber)
|
||||||
|
assertEquals(15, action.episodeNumber)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
|
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
|
||||||
val meta = MetaDetails(
|
val meta = MetaDetails(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
package com.nuvio.app.features.home
|
package com.nuvio.app.features.home
|
||||||
|
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryFile
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryItem
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryItemType
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryProviderState
|
||||||
|
import com.nuvio.app.features.cloud.CloudLibraryUiState
|
||||||
|
import com.nuvio.app.features.cloud.playbackVideoId
|
||||||
|
import com.nuvio.app.features.debrid.DebridProviders
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class HomeScreenTest {
|
class HomeScreenTest {
|
||||||
|
|
||||||
|
|
@ -84,6 +93,45 @@ class HomeScreenTest {
|
||||||
assertEquals("S1E4 • Current", result.single().subtitle)
|
assertEquals("S1E4 • Current", result.single().subtitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build home continue watching items enriches cloud title from library file`() {
|
||||||
|
val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv")
|
||||||
|
val cloudItem = CloudLibraryItem(
|
||||||
|
providerId = DebridProviders.TORBOX_ID,
|
||||||
|
providerName = DebridProviders.Torbox.displayName,
|
||||||
|
id = "29773238",
|
||||||
|
type = CloudLibraryItemType.Torrent,
|
||||||
|
name = "GOAT torrent",
|
||||||
|
files = listOf(file),
|
||||||
|
)
|
||||||
|
val progress = WatchProgressEntry(
|
||||||
|
contentType = "cloud",
|
||||||
|
parentMetaId = cloudItem.stableKey,
|
||||||
|
parentMetaType = "cloud",
|
||||||
|
videoId = cloudItem.playbackVideoId(file),
|
||||||
|
title = cloudItem.stableKey,
|
||||||
|
lastPositionMs = 120_000L,
|
||||||
|
durationMs = 1_000_000L,
|
||||||
|
lastUpdatedEpochMs = 500L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = buildHomeContinueWatchingItems(
|
||||||
|
visibleEntries = listOf(progress),
|
||||||
|
nextUpItemsBySeries = emptyMap(),
|
||||||
|
cloudLibraryUiState = CloudLibraryUiState(
|
||||||
|
isLoaded = true,
|
||||||
|
providers = listOf(
|
||||||
|
CloudLibraryProviderState(
|
||||||
|
provider = DebridProviders.Torbox,
|
||||||
|
items = listOf(cloudItem),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("GOAT.2026.2160p.UHD.mkv", result.single().title)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
|
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
|
||||||
val oldEntry = progressEntry(
|
val oldEntry = progressEntry(
|
||||||
|
|
@ -146,6 +194,85 @@ class HomeScreenTest {
|
||||||
assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
|
assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `home next up seed uses completed progress when watched item lags on Nuvio Sync`() {
|
||||||
|
val completedProgress = progressEntry(
|
||||||
|
videoId = "show:4:14",
|
||||||
|
title = "Show",
|
||||||
|
seasonNumber = 4,
|
||||||
|
episodeNumber = 14,
|
||||||
|
lastUpdatedEpochMs = 2_000L,
|
||||||
|
isCompleted = true,
|
||||||
|
)
|
||||||
|
val olderWatchedItem = watchedItem(
|
||||||
|
id = "show",
|
||||||
|
season = 4,
|
||||||
|
episode = 10,
|
||||||
|
markedAtEpochMs = 1_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = buildHomeNextUpSeedCandidates(
|
||||||
|
progressEntries = listOf(completedProgress),
|
||||||
|
watchedItems = listOf(olderWatchedItem),
|
||||||
|
isTraktProgressActive = false,
|
||||||
|
preferFurthestEpisode = true,
|
||||||
|
nowEpochMs = 3_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("show", result.single().content.id)
|
||||||
|
assertEquals(4, result.single().seasonNumber)
|
||||||
|
assertEquals(14, result.single().episodeNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `home next up seed uses furthest watched item when progress is older`() {
|
||||||
|
val olderCompletedProgress = progressEntry(
|
||||||
|
videoId = "show:4:10",
|
||||||
|
title = "Show",
|
||||||
|
seasonNumber = 4,
|
||||||
|
episodeNumber = 10,
|
||||||
|
lastUpdatedEpochMs = 2_000L,
|
||||||
|
isCompleted = true,
|
||||||
|
)
|
||||||
|
val newerWatchedItem = watchedItem(
|
||||||
|
id = "show",
|
||||||
|
season = 4,
|
||||||
|
episode = 14,
|
||||||
|
markedAtEpochMs = 1_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = buildHomeNextUpSeedCandidates(
|
||||||
|
progressEntries = listOf(olderCompletedProgress),
|
||||||
|
watchedItems = listOf(newerWatchedItem),
|
||||||
|
isTraktProgressActive = false,
|
||||||
|
preferFurthestEpisode = true,
|
||||||
|
nowEpochMs = 3_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(4, result.single().seasonNumber)
|
||||||
|
assertEquals(14, result.single().episodeNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stale live next up item is dropped when current seed advances`() {
|
||||||
|
val staleNextUp = continueWatchingItem(
|
||||||
|
videoId = "show:4:11",
|
||||||
|
subtitle = "Up Next • S4E11",
|
||||||
|
seedSeasonNumber = 4,
|
||||||
|
seedEpisodeNumber = 10,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = filterNextUpItemsByCurrentSeeds(
|
||||||
|
nextUpItemsBySeries = mapOf("show" to (1_000L to staleNextUp)),
|
||||||
|
activeSeedContentIds = setOf("show"),
|
||||||
|
currentSeedByContentId = mapOf("show" to (4 to 14)),
|
||||||
|
shouldDropItemsWithoutActiveSeed = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
private fun progressEntry(
|
private fun progressEntry(
|
||||||
videoId: String,
|
videoId: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -153,6 +280,7 @@ class HomeScreenTest {
|
||||||
seasonNumber: Int? = 1,
|
seasonNumber: Int? = 1,
|
||||||
episodeNumber: Int? = 4,
|
episodeNumber: Int? = 4,
|
||||||
episodeTitle: String? = "Episode",
|
episodeTitle: String? = "Episode",
|
||||||
|
isCompleted: Boolean = false,
|
||||||
): WatchProgressEntry =
|
): WatchProgressEntry =
|
||||||
WatchProgressEntry(
|
WatchProgressEntry(
|
||||||
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
||||||
|
|
@ -166,11 +294,16 @@ class HomeScreenTest {
|
||||||
lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L,
|
lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L,
|
||||||
durationMs = 1_000_000L,
|
durationMs = 1_000_000L,
|
||||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||||
|
isCompleted = isCompleted,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun continueWatchingItem(
|
private fun continueWatchingItem(
|
||||||
videoId: String,
|
videoId: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
|
seasonNumber: Int? = 1,
|
||||||
|
episodeNumber: Int? = 4,
|
||||||
|
seedSeasonNumber: Int? = seasonNumber,
|
||||||
|
seedEpisodeNumber: Int? = episodeNumber,
|
||||||
): ContinueWatchingItem =
|
): ContinueWatchingItem =
|
||||||
ContinueWatchingItem(
|
ContinueWatchingItem(
|
||||||
parentMetaId = videoId.substringBefore(':'),
|
parentMetaId = videoId.substringBefore(':'),
|
||||||
|
|
@ -179,14 +312,32 @@ class HomeScreenTest {
|
||||||
title = "Show",
|
title = "Show",
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
imageUrl = null,
|
imageUrl = null,
|
||||||
seasonNumber = 1,
|
seasonNumber = seasonNumber,
|
||||||
episodeNumber = 4,
|
episodeNumber = episodeNumber,
|
||||||
episodeTitle = subtitle.substringAfterLast(" • ", "Episode"),
|
episodeTitle = subtitle.substringAfterLast(" • ", "Episode"),
|
||||||
|
isNextUp = true,
|
||||||
|
nextUpSeedSeasonNumber = seedSeasonNumber,
|
||||||
|
nextUpSeedEpisodeNumber = seedEpisodeNumber,
|
||||||
resumePositionMs = 0L,
|
resumePositionMs = 0L,
|
||||||
durationMs = 0L,
|
durationMs = 0L,
|
||||||
progressFraction = 0f,
|
progressFraction = 0f,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun watchedItem(
|
||||||
|
id: String,
|
||||||
|
season: Int,
|
||||||
|
episode: Int,
|
||||||
|
markedAtEpochMs: Long,
|
||||||
|
): WatchedItem =
|
||||||
|
WatchedItem(
|
||||||
|
id = id,
|
||||||
|
type = "series",
|
||||||
|
name = "Show",
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
markedAtEpochMs = markedAtEpochMs,
|
||||||
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
|
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ package com.nuvio.app.features.streams
|
||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class StreamAutoPlaySelectorTest {
|
class StreamAutoPlaySelectorTest {
|
||||||
|
|
||||||
|
|
@ -145,16 +147,140 @@ class StreamAutoPlaySelectorTest {
|
||||||
assertNull(selected)
|
assertNull(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first stream mode can select direct debrid candidate without resolved URL`() {
|
||||||
|
val directDebrid = stream(
|
||||||
|
addonName = "Torbox Instant",
|
||||||
|
url = null,
|
||||||
|
name = "TB Instant",
|
||||||
|
directDebrid = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
|
||||||
|
streams = listOf(directDebrid),
|
||||||
|
mode = StreamAutoPlayMode.FIRST_STREAM,
|
||||||
|
regexPattern = "",
|
||||||
|
source = StreamAutoPlaySource.ALL_SOURCES,
|
||||||
|
installedAddonNames = emptySet(),
|
||||||
|
selectedAddons = emptySet(),
|
||||||
|
selectedPlugins = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(directDebrid, selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timeout evaluation keeps pending regex debrid candidate open`() {
|
||||||
|
val pending = stream(
|
||||||
|
addonName = "Torrentio",
|
||||||
|
name = "The Show 1080p",
|
||||||
|
infoHash = "hash-pending",
|
||||||
|
cacheState = StreamDebridCacheState.CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
|
||||||
|
streams = listOf(pending),
|
||||||
|
mode = StreamAutoPlayMode.REGEX_MATCH,
|
||||||
|
regexPattern = "1080p",
|
||||||
|
source = StreamAutoPlaySource.ALL_SOURCES,
|
||||||
|
installedAddonNames = setOf("Torrentio"),
|
||||||
|
selectedAddons = emptySet(),
|
||||||
|
selectedPlugins = emptySet(),
|
||||||
|
debridEnabled = true,
|
||||||
|
activeResolverProviderId = "premiumize",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(evaluation.stream)
|
||||||
|
assertTrue(evaluation.hasPendingDebridCandidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timeout evaluation still selects direct link while debrid candidate is pending`() {
|
||||||
|
val pending = stream(
|
||||||
|
addonName = "Torrentio",
|
||||||
|
name = "The Show 1080p",
|
||||||
|
infoHash = "hash-pending",
|
||||||
|
cacheState = StreamDebridCacheState.CHECKING,
|
||||||
|
)
|
||||||
|
val direct = stream(
|
||||||
|
addonName = "Direct Addon",
|
||||||
|
url = "https://example.com/video.mp4",
|
||||||
|
name = "The Show 1080p",
|
||||||
|
)
|
||||||
|
|
||||||
|
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
|
||||||
|
streams = listOf(pending, direct),
|
||||||
|
mode = StreamAutoPlayMode.REGEX_MATCH,
|
||||||
|
regexPattern = "1080p",
|
||||||
|
source = StreamAutoPlaySource.ALL_SOURCES,
|
||||||
|
installedAddonNames = setOf("Torrentio", "Direct Addon"),
|
||||||
|
selectedAddons = emptySet(),
|
||||||
|
selectedPlugins = emptySet(),
|
||||||
|
debridEnabled = true,
|
||||||
|
activeResolverProviderId = "premiumize",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(direct, evaluation.stream)
|
||||||
|
assertFalse(evaluation.hasPendingDebridCandidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `direct debrid candidate must match active resolver`() {
|
||||||
|
val torbox = stream(
|
||||||
|
addonName = "Comet",
|
||||||
|
name = "TB Instant",
|
||||||
|
directDebrid = true,
|
||||||
|
directDebridService = "torbox",
|
||||||
|
)
|
||||||
|
|
||||||
|
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
|
||||||
|
streams = listOf(torbox),
|
||||||
|
mode = StreamAutoPlayMode.FIRST_STREAM,
|
||||||
|
regexPattern = "",
|
||||||
|
source = StreamAutoPlaySource.ALL_SOURCES,
|
||||||
|
installedAddonNames = setOf("Comet"),
|
||||||
|
selectedAddons = emptySet(),
|
||||||
|
selectedPlugins = emptySet(),
|
||||||
|
debridEnabled = true,
|
||||||
|
activeResolverProviderId = "premiumize",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(evaluation.stream)
|
||||||
|
assertFalse(evaluation.hasPendingDebridCandidate)
|
||||||
|
}
|
||||||
|
|
||||||
private fun stream(
|
private fun stream(
|
||||||
addonName: String,
|
addonName: String,
|
||||||
url: String? = null,
|
url: String? = null,
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
bingeGroup: String? = null,
|
bingeGroup: String? = null,
|
||||||
|
directDebrid: Boolean = false,
|
||||||
|
directDebridService: String = "torbox",
|
||||||
|
infoHash: String? = null,
|
||||||
|
cacheState: StreamDebridCacheState? = null,
|
||||||
): StreamItem = StreamItem(
|
): StreamItem = StreamItem(
|
||||||
name = name,
|
name = name,
|
||||||
url = url,
|
url = url,
|
||||||
|
infoHash = infoHash,
|
||||||
addonName = addonName,
|
addonName = addonName,
|
||||||
addonId = addonName,
|
addonId = "addon:$addonName",
|
||||||
|
clientResolve = if (directDebrid) {
|
||||||
|
StreamClientResolve(
|
||||||
|
type = "debrid",
|
||||||
|
service = directDebridService,
|
||||||
|
isCached = true,
|
||||||
|
infoHash = "hash",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
debridCacheStatus = cacheState?.let { state ->
|
||||||
|
StreamDebridCacheStatus(
|
||||||
|
providerId = "premiumize",
|
||||||
|
providerName = "Premiumize",
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
},
|
||||||
behaviorHints = StreamBehaviorHints(
|
behaviorHints = StreamBehaviorHints(
|
||||||
bingeGroup = bingeGroup,
|
bingeGroup = bingeGroup,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -120,4 +120,55 @@ class StreamParserTest {
|
||||||
assertEquals("ok", responseHeaders["x-test"])
|
assertEquals("ok", responseHeaders["x-test"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse keeps client resolve metadata without direct URL`() {
|
||||||
|
val streams = StreamParser.parse(
|
||||||
|
payload =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"name": "Instant",
|
||||||
|
"clientResolve": {
|
||||||
|
"type": "debrid",
|
||||||
|
"infoHash": "abc123",
|
||||||
|
"fileIdx": 4,
|
||||||
|
"sources": ["udp://tracker.example"],
|
||||||
|
"torrentName": "Movie Pack",
|
||||||
|
"filename": "Movie.2024.2160p.mkv",
|
||||||
|
"service": "torbox",
|
||||||
|
"isCached": true,
|
||||||
|
"stream": {
|
||||||
|
"raw": {
|
||||||
|
"size": 1610612736,
|
||||||
|
"indexer": "Indexer",
|
||||||
|
"parsed": {
|
||||||
|
"parsed_title": "Movie",
|
||||||
|
"year": 2024,
|
||||||
|
"resolution": "2160p",
|
||||||
|
"hdr": ["DV"],
|
||||||
|
"audio": ["Atmos"],
|
||||||
|
"episodes": [1, 2],
|
||||||
|
"bit_depth": "10bit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
addonName = "Debrid Fixture",
|
||||||
|
addonId = "debrid:torbox",
|
||||||
|
)
|
||||||
|
|
||||||
|
val stream = streams.single()
|
||||||
|
assertTrue(stream.isDirectDebridStream)
|
||||||
|
assertFalse(stream.isTorrentStream)
|
||||||
|
assertEquals("abc123", stream.clientResolve?.infoHash)
|
||||||
|
assertEquals(4, stream.clientResolve?.fileIdx)
|
||||||
|
assertEquals("udp://tracker.example", stream.clientResolve?.sources?.single())
|
||||||
|
assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution)
|
||||||
|
assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.nuvio.app.features.watchprogress
|
package com.nuvio.app.features.watchprogress
|
||||||
|
|
||||||
|
import com.nuvio.app.features.cloud.TorboxCloudLibraryPosterUrl
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
@ -95,6 +96,23 @@ class WatchProgressRulesTest {
|
||||||
assertEquals(2, result.size)
|
assertEquals(2, result.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cloud continue watching uses provider poster fallback`() {
|
||||||
|
val item = WatchProgressEntry(
|
||||||
|
contentType = "cloud",
|
||||||
|
parentMetaId = "torbox:Torrent:29773238",
|
||||||
|
parentMetaType = "cloud",
|
||||||
|
videoId = "torbox:Torrent:29773238:8",
|
||||||
|
title = "Cloud file",
|
||||||
|
lastPositionMs = 120_000L,
|
||||||
|
durationMs = 1_000_000L,
|
||||||
|
lastUpdatedEpochMs = 1L,
|
||||||
|
).toContinueWatchingItem()
|
||||||
|
|
||||||
|
assertEquals(TorboxCloudLibraryPosterUrl, item.poster)
|
||||||
|
assertEquals(TorboxCloudLibraryPosterUrl, item.imageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `continue watching excludes explicit 100 percent entries even when completion flag is false`() {
|
fun `continue watching excludes explicit 100 percent entries even when completion flag is false`() {
|
||||||
val completedByPercent = entry(
|
val completedByPercent = entry(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
|
import com.nuvio.app.core.sync.decodeSyncBoolean
|
||||||
|
import com.nuvio.app.core.sync.decodeSyncInt
|
||||||
|
import com.nuvio.app.core.sync.decodeSyncString
|
||||||
|
import com.nuvio.app.core.sync.encodeSyncBoolean
|
||||||
|
import com.nuvio.app.core.sync.encodeSyncInt
|
||||||
|
import com.nuvio.app.core.sync.encodeSyncString
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import platform.Foundation.NSUserDefaults
|
||||||
|
|
||||||
|
actual object DebridSettingsStorage {
|
||||||
|
private const val enabledKey = "debrid_enabled"
|
||||||
|
private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled"
|
||||||
|
private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id"
|
||||||
|
private const val torboxApiKeyKey = "debrid_torbox_api_key"
|
||||||
|
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
|
||||||
|
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
|
||||||
|
private const val streamMaxResultsKey = "debrid_stream_max_results"
|
||||||
|
private const val streamSortModeKey = "debrid_stream_sort_mode"
|
||||||
|
private const val streamMinimumQualityKey = "debrid_stream_minimum_quality"
|
||||||
|
private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter"
|
||||||
|
private const val streamHdrFilterKey = "debrid_stream_hdr_filter"
|
||||||
|
private const val streamCodecFilterKey = "debrid_stream_codec_filter"
|
||||||
|
private const val streamPreferencesKey = "debrid_stream_preferences"
|
||||||
|
private const val streamNameTemplateKey = "debrid_stream_name_template"
|
||||||
|
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
|
||||||
|
private fun syncKeys(): List<String> =
|
||||||
|
listOf(
|
||||||
|
enabledKey,
|
||||||
|
cloudLibraryEnabledKey,
|
||||||
|
preferredResolverProviderIdKey,
|
||||||
|
instantPlaybackPreparationLimitKey,
|
||||||
|
streamMaxResultsKey,
|
||||||
|
streamSortModeKey,
|
||||||
|
streamMinimumQualityKey,
|
||||||
|
streamDolbyVisionFilterKey,
|
||||||
|
streamHdrFilterKey,
|
||||||
|
streamCodecFilterKey,
|
||||||
|
streamPreferencesKey,
|
||||||
|
streamNameTemplateKey,
|
||||||
|
streamDescriptionTemplateKey,
|
||||||
|
) + DebridProviders.all().map { providerApiKeyKey(it.id) }
|
||||||
|
|
||||||
|
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
|
||||||
|
|
||||||
|
actual fun saveEnabled(enabled: Boolean) {
|
||||||
|
saveBoolean(enabledKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey)
|
||||||
|
|
||||||
|
actual fun saveCloudLibraryEnabled(enabled: Boolean) {
|
||||||
|
saveBoolean(cloudLibraryEnabledKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey)
|
||||||
|
|
||||||
|
actual fun savePreferredResolverProviderId(providerId: String) {
|
||||||
|
saveString(preferredResolverProviderIdKey, providerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadProviderApiKey(providerId: String): String? =
|
||||||
|
loadString(providerApiKeyKey(providerId))
|
||||||
|
|
||||||
|
actual fun saveProviderApiKey(providerId: String, apiKey: String) {
|
||||||
|
saveString(providerApiKeyKey(providerId), apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID)
|
||||||
|
|
||||||
|
actual fun saveTorboxApiKey(apiKey: String) {
|
||||||
|
saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID)
|
||||||
|
|
||||||
|
actual fun saveRealDebridApiKey(apiKey: String) {
|
||||||
|
saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
|
||||||
|
|
||||||
|
actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
|
||||||
|
saveInt(instantPlaybackPreparationLimitKey, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey)
|
||||||
|
|
||||||
|
actual fun saveStreamMaxResults(maxResults: Int) {
|
||||||
|
saveInt(streamMaxResultsKey, maxResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey)
|
||||||
|
|
||||||
|
actual fun saveStreamSortMode(mode: String) {
|
||||||
|
saveString(streamSortModeKey, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey)
|
||||||
|
|
||||||
|
actual fun saveStreamMinimumQuality(quality: String) {
|
||||||
|
saveString(streamMinimumQualityKey, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey)
|
||||||
|
|
||||||
|
actual fun saveStreamDolbyVisionFilter(filter: String) {
|
||||||
|
saveString(streamDolbyVisionFilterKey, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey)
|
||||||
|
|
||||||
|
actual fun saveStreamHdrFilter(filter: String) {
|
||||||
|
saveString(streamHdrFilterKey, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey)
|
||||||
|
|
||||||
|
actual fun saveStreamCodecFilter(filter: String) {
|
||||||
|
saveString(streamCodecFilterKey, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey)
|
||||||
|
|
||||||
|
actual fun saveStreamPreferences(preferences: String) {
|
||||||
|
saveString(streamPreferencesKey, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey)
|
||||||
|
|
||||||
|
actual fun saveStreamNameTemplate(template: String) {
|
||||||
|
saveString(streamNameTemplateKey, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey)
|
||||||
|
|
||||||
|
actual fun saveStreamDescriptionTemplate(template: String) {
|
||||||
|
saveString(streamDescriptionTemplateKey, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBoolean(key: String): Boolean? {
|
||||||
|
val defaults = NSUserDefaults.standardUserDefaults
|
||||||
|
val scopedKey = ProfileScopedKey.of(key)
|
||||||
|
return if (defaults.objectForKey(scopedKey) != null) {
|
||||||
|
defaults.boolForKey(scopedKey)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBoolean(key: String, enabled: Boolean) {
|
||||||
|
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInt(key: String): Int? {
|
||||||
|
val defaults = NSUserDefaults.standardUserDefaults
|
||||||
|
val scopedKey = ProfileScopedKey.of(key)
|
||||||
|
return if (defaults.objectForKey(scopedKey) != null) {
|
||||||
|
defaults.integerForKey(scopedKey).toInt()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveInt(key: String, value: Int) {
|
||||||
|
NSUserDefaults.standardUserDefaults.setInteger(value.toLong(), forKey = ProfileScopedKey.of(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadString(key: String): String? =
|
||||||
|
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key))
|
||||||
|
|
||||||
|
private fun saveString(key: String, value: String) {
|
||||||
|
NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||||
|
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
|
||||||
|
loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) }
|
||||||
|
loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) }
|
||||||
|
DebridProviders.all().forEach { provider ->
|
||||||
|
loadProviderApiKey(provider.id)?.let {
|
||||||
|
put(providerApiKeyKey(provider.id), encodeSyncString(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
|
||||||
|
loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
|
||||||
|
loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
|
||||||
|
loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) }
|
||||||
|
loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) }
|
||||||
|
loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) }
|
||||||
|
loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) }
|
||||||
|
loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) }
|
||||||
|
loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) }
|
||||||
|
loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
||||||
|
syncKeys().forEach { key ->
|
||||||
|
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
|
||||||
|
payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled)
|
||||||
|
payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId)
|
||||||
|
DebridProviders.all().forEach { provider ->
|
||||||
|
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey ->
|
||||||
|
saveProviderApiKey(provider.id, apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
|
||||||
|
payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
|
||||||
|
payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
|
||||||
|
payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality)
|
||||||
|
payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter)
|
||||||
|
payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter)
|
||||||
|
payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter)
|
||||||
|
payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences)
|
||||||
|
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
|
||||||
|
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun providerApiKeyKey(providerId: String): String {
|
||||||
|
val normalized = DebridProviders.byId(providerId)?.id
|
||||||
|
?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_")
|
||||||
|
return when (normalized) {
|
||||||
|
DebridProviders.TORBOX_ID -> torboxApiKeyKey
|
||||||
|
DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey
|
||||||
|
else -> "debrid_${normalized}_api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=64
|
CURRENT_PROJECT_VERSION=64
|
||||||
MARKETING_VERSION=0.1.22
|
MARKETING_VERSION=0.1.0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue