Merge branch 'cmp-rewrite' into indonesian-locale

This commit is contained in:
Luqman Fadlli 2026-05-16 06:44:55 +07:00 committed by GitHub
commit cbdf350663
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 4281 additions and 127 deletions

View file

@ -4,44 +4,64 @@
## PR type
<!-- Pick one and delete the others -->
- Bug fix
- Small maintenance improvement
- Docs fix
- Translation update
- Approved larger change (link approval below)
<!-- Check exactly one. PRs outside these types are not accepted. -->
- [ ] Reproducible bug fix
- [ ] UI glitch/bug fix
- [ ] Behavior bug/regression fix
- [ ] Small maintenance only, with no UI or behavior change
- [ ] Docs accuracy fix
- [ ] Translation/localization only
- [ ] Approved larger or directional change
## Why
<!-- Why this change is needed. Link bug/issue/context. -->
<!-- Why this change is needed. Explain the user-visible bug, regression, maintenance need, docs error, or approved request. -->
## Issue or approval
<!-- Required. Link the bug issue or approved feature request. For docs/translation/maintenance with no issue, explain why no issue is needed. -->
<!-- Examples: Fixes #123 / Approved in #456 / No linked issue: typo-only docs fix. -->
## UI / behavior impact
<!-- Check every box that applies. At least one must be checked. -->
- [ ] No UI change
- [ ] No behavior change
- [ ] UI changed only to fix a documented glitch/bug
- [ ] Behavior changed only to fix a documented bug/regression
- [ ] UI change has explicit maintainer approval
- [ ] Behavior change has explicit maintainer approval
## Policy check
<!-- ALL boxes must be checked or the PR will be closed without review. -->
- [ ] This PR is not cosmetic-only, unless it is a translation PR.
- [ ] This PR does not add a new major feature without prior approval.
- [ ] This PR is small in scope and focused on one problem.
- [ ] If this is a larger or directional change, I linked the **approved** feature request issue below.
- [ ] I have read and understood `CONTRIBUTING.md`.
- [ ] This PR is small, focused, and limited to one problem.
- [ ] This PR is not cosmetic-only.
- [ ] Any UI change fixes a linked glitch/bug and includes visual proof, or this PR has no UI change.
- [ ] Any behavior change fixes a linked bug/regression or has explicit approval, or this PR has no behavior change.
- [ ] This PR does not bundle unrelated refactors, cleanups, formatting, or drive-by changes.
- [ ] This PR does not add dependencies, architecture changes, migrations, or product-direction changes without explicit approval.
- [ ] I listed the testing performed below.
> **Large PRs without a linked, approved feature request issue will be closed immediately without review. No exceptions.**
> UI polish, cosmetic-only changes, minor behavior tweaks, and unapproved product changes will be closed without review.
## Approved feature request (required for large/non-trivial PRs)
## Scope boundaries
<!-- Link the approved feature request issue. Delete this section ONLY for small bug fixes. -->
<!-- Example: Approved in #123 -->
<!-- List anything intentionally not changed. If this is a bug fix, confirm it does not include extra UI polish or behavior tweaks. -->
## Testing
<!-- What you tested and how (manual + automated). -->
<!-- What you tested and how. Include devices/emulators, commands, and manual flows. Do not write only "not tested" unless this is docs/translation-only. -->
## Screenshots / Video (UI changes only)
<!-- If UI changed, add before/after screenshots or a short clip. -->
<!-- Required for any UI change. Write "Not a UI change" only if no UI changed. -->
## Breaking changes
<!-- Any breaking behavior/config/schema changes? If none, write: None -->
<!-- Any breaking behavior/config/schema changes? If none, write: None. -->
## Linked issues
<!-- Example: Fixes #123 -->
<!-- Required for bug fixes, UI glitch fixes, behavior fixes, and approved changes. For docs/translation/maintenance with no issue, write: No linked issue - reason. -->

View file

@ -29,9 +29,44 @@ jobs:
return (next === -1 ? rest : rest.slice(0, next)).trim();
}
const required = ["Summary", "Why", "Testing", "Breaking changes", "Linked issues"];
function cleanedContent(title) {
const content = sectionContent(title);
if (content === null) return null;
return content
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/`/g, "")
.replace(/\s+/g, " ")
.trim();
}
function checkedLines(content) {
return (content || "").match(/^\s*-\s+\[[xX]\]\s+.+$/gm) || [];
}
function uncheckedLines(content) {
return (content || "").match(/^\s*-\s+\[\s\]\s+.+$/gm) || [];
}
function hasIssueReference(content) {
return /(^|\s)(#\d+|https:\/\/github\.com\/\S+\/issues\/\d+)/i.test(content || "");
}
const required = [
"Summary",
"PR type",
"Why",
"Issue or approval",
"UI / behavior impact",
"Policy check",
"Scope boundaries",
"Testing",
"Screenshots / Video",
"Breaking changes",
"Linked issues",
];
const missing = [];
const empty = [];
const failedRules = [];
for (const name of required) {
const content = sectionContent(name);
@ -40,34 +75,73 @@ jobs:
continue;
}
const cleaned = content
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/`/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
const cleaned = cleanedContent(name);
const normalized = cleaned.toLowerCase();
const allowsNone = name === "Breaking changes" || name === "Screenshots / Video";
if (
cleaned.length < 4 ||
cleaned === "none" ||
cleaned.includes("what changed in this pr") ||
cleaned.includes("why this change is needed") ||
cleaned.includes("what you tested") ||
cleaned.includes("example: fixes #123")
(!allowsNone && ["none", "n/a", "na", "not applicable"].includes(normalized)) ||
normalized.includes("what changed in this pr") ||
normalized.includes("why this change is needed") ||
normalized.includes("what you tested") ||
normalized.includes("example: fixes #123")
) {
empty.push(name);
}
}
if (missing.length || empty.length) {
const prTypeContent = sectionContent("PR type") || "";
const prTypeChecked = checkedLines(prTypeContent);
if (sectionContent("PR type") !== null && prTypeChecked.length !== 1) {
failedRules.push("Check exactly one PR type.");
}
const impactContent = sectionContent("UI / behavior impact") || "";
const impactChecked = checkedLines(impactContent);
if (sectionContent("UI / behavior impact") !== null && impactChecked.length === 0) {
failedRules.push("Check at least one UI / behavior impact box.");
}
const policyContent = sectionContent("Policy check") || "";
const policyChecked = checkedLines(policyContent);
const policyUnchecked = uncheckedLines(policyContent);
if (sectionContent("Policy check") !== null && policyChecked.length === 0) {
failedRules.push("Policy check must include checked boxes.");
}
if (policyUnchecked.length) {
failedRules.push("Every Policy check box must be checked.");
}
const checkedTypeText = prTypeChecked.join(" ");
const issueRequired =
/bug fix|ui glitch|behavior bug|approved larger|approved directional/i.test(checkedTypeText);
const issueText = [
cleanedContent("Issue or approval") || "",
cleanedContent("Linked issues") || "",
].join(" ");
if (issueRequired && !hasIssueReference(issueText)) {
failedRules.push("Bug fixes, UI glitch fixes, behavior fixes, and approved changes must link an issue or approved request.");
}
const uiChanged = checkedLines(impactContent).some((line) =>
/UI changed only to fix a documented glitch\/bug|UI change has explicit maintainer approval/i.test(line)
);
const screenshotText = (cleanedContent("Screenshots / Video") || "").toLowerCase();
if (uiChanged && ["none", "n/a", "na", "not a ui change", "not applicable"].includes(screenshotText)) {
failedRules.push("UI changes must include before/after screenshots or video.");
}
if (missing.length || empty.length || failedRules.length) {
const lines = [
"PR description is missing required detail.",
"",
];
if (missing.length) lines.push(`Missing sections: ${missing.join(", ")}`);
if (empty.length) lines.push(`Incomplete sections: ${empty.join(", ")}`);
if (failedRules.length) lines.push(`Failed policy rules: ${failedRules.join(" ")}`);
lines.push("");
lines.push("Please fill the PR template before merging.");
lines.push("Please complete the PR template and make sure the PR fits CONTRIBUTING.md before review.");
core.setFailed(lines.join("\n"));
} else {
core.info("PR template check passed.");

View file

@ -2,33 +2,84 @@
Thanks for helping improve Nuvio.
## Strict rules read before opening anything
## Strict rules - read before opening anything
These rules are enforced strictly. Issues and PRs that do not follow them will be closed without review.
---
## PR policy
## What PRs are for
Pull requests are currently intended for:
Pull requests are accepted only when they fit one of these categories:
- Reproducible bug fixes
- Small stability improvements
- Minor maintenance work
- Reproducible bug fixes for documented issues
- UI glitch fixes for visible bugs or regressions, with before/after proof
- Behavior bug fixes that restore expected behavior without changing product direction
- Small maintenance work that does not change UI, UX, behavior, dependencies, architecture, or public contracts
- Small documentation fixes that improve accuracy
- Translation updates
- Translation/localization updates
Pull requests are generally **not** accepted for:
Pull requests are not accepted for:
- New major features
- Product direction changes
- Large UX / UI redesigns
- Cosmetic-only changes
- Refactors without a clear user-facing or maintenance benefit
- UX/UI redesigns
- Cosmetic-only UI changes
- "Minor polish" changes to colors, spacing, typography, icons, copy, layout, animations, or visual style
- Behavior changes that are not tied to a reproducible bug or approved feature request
- Refactors without a clear maintenance need
- Dependency additions or architecture changes without prior approval
Translation PRs are allowed, as long as they stay focused on translation/localization work and do not bundle unrelated feature or UI changes.
### Large PRs and large changes
---
## UI changes
Do not open a pull request for a UI change just because it looks better, cleaner, more modern, or more consistent to you.
UI PRs are accepted only when they fix a specific, documented glitch or bug, such as:
- Broken layout
- Overlapping or clipped text
- Unreadable content
- Incorrect visual state
- Navigation, gesture, or focus glitches
- A visible regression from a previous version
- A crash, blank screen, or unusable screen caused by UI code
Every UI PR must include:
- A linked bug issue
- A short explanation of the exact glitch being fixed
- Before and after screenshots or a short video
- The smallest possible change that fixes the glitch
Cosmetic-only UI PRs will be closed, even if the change is small.
---
## Behavior changes
Behavior includes, but is not limited to, playback, stream/source selection, resume state, watched state, search, sync, settings defaults, navigation, gestures, error handling, caching, networking, storage, downloads, offline behavior, and account-related flows.
Do not open a PR that changes behavior unless one of these is true:
- It fixes a linked, reproducible bug or regression and restores the intended behavior.
- It links an approved feature request where a maintainer explicitly approved implementation.
Behavior PRs must explain:
- The old behavior
- The broken or unwanted behavior
- The new behavior
- How the behavior was tested
Minor behavior tweaks are still behavior changes. They need the same issue link or approval.
---
## Large PRs and large changes
**Any large PR or change that is not a simple bug fix must be discussed and approved via a feature request issue first.**
@ -38,7 +89,9 @@ Translation PRs are allowed, as long as they stay focused on translation/localiz
PRs that introduce large changes without a linked, approved feature request **will not be reviewed at all** and will be closed immediately. No exceptions.
This applies to — but is not limited to — UI changes, new features, architecture changes, dependency additions, and large refactors.
This applies to UI changes, behavior changes, new features, architecture changes, dependency additions, large refactors, migrations, and changes that affect product direction.
Approval means a maintainer has clearly said the implementation is approved. A feature request being open, popular, or labeled `enhancement` is not approval.
---
@ -100,14 +153,26 @@ Opening a feature request does **not** mean a pull request will be accepted for
Please make sure your PR is all of the following:
- Small in scope
- Focused on one problem
- Allowed by this policy
- Small in scope and focused on one problem
- Clearly aligned with the current direction of the project
- Not cosmetic-only, unless it is a translation PR
- Not a new major feature unless it was discussed and approved first
- **If large or non-trivial: linked to an approved feature request issue**
- Not cosmetic-only
- Not changing behavior unless it fixes a linked bug or has explicit approval
- Not changing UI unless it fixes a linked glitch/bug and includes visual proof
- Not bundling refactors, cleanups, or drive-by changes with a bug fix
- Tested manually and/or automatically in a way that matches the risk
- Linked to an approved feature request issue if large, directional, or non-trivial
PRs that do not fit this policy will be closed without merge so review time can stay focused on bugs, regressions, and small improvements.
PRs will be closed without review if they:
- Are cosmetic-only UI changes
- Change behavior without a linked bug or approved feature request
- Change UI without screenshots/video
- Bundle unrelated changes
- Leave the PR template incomplete
- Add dependencies, architecture changes, or broad refactors without approval
Review time is reserved for bugs, regressions, stability, translations, documentation accuracy, and approved work.
---

View file

@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
)
}
outDir.resolve("com/nuvio/app/features/debrid").apply {
mkdirs()
resolve("DebridConfig.kt").writeText(
"""
|package com.nuvio.app.features.debrid
|
|object DebridConfig {
| const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}"
|}
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/core/build").apply {
mkdirs()
resolve("AppVersionConfig.kt").writeText(

View file

@ -15,6 +15,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
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.DownloadsPlatformDownloader
import com.nuvio.app.features.downloads.DownloadsStorage
@ -73,6 +74,7 @@ class MainActivity : AppCompatActivity() {
SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext)
DebridSettingsStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext)
TraktAuthStorage.initialize(applicationContext)

View file

@ -0,0 +1,140 @@
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 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 streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
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 loadTorboxApiKey(): String? = loadString(torboxApiKeyKey)
actual fun saveTorboxApiKey(apiKey: String) {
saveString(torboxApiKeyKey, apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey)
actual fun saveRealDebridApiKey(apiKey: String) {
saveString(realDebridApiKeyKey, apiKey)
}
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
saveInt(instantPlaybackPreparationLimitKey, limit)
}
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)) }
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(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.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
}

View file

@ -1068,7 +1068,7 @@
<string name="streams_resume_from_percent">Pokračovat od %1$d%%</string>
<string name="streams_resume_from_time">Pokračovat od %1$s</string>
<string name="streams_size">VELIKOST %1$s</string>
<string name="streams_torrent_not_supported">Torrent streamy nejsou podporovány</string>
<string name="streams_torrent_not_supported">Tento typ streamu není podporován</string>
<string name="trailer_close">Zavřít trailer</string>
<string name="trailer_unable_to_play">Trailer nelze přehrát</string>
<string name="trakt_lists_load_failed">Nepodařilo se načíst seznamy Trakt</string>

View file

@ -1111,6 +1111,7 @@
<string name="external_player_failed">Tidak dapat membuka player eksternal</string>
<string name="external_player_not_configured">Pilih player eksternal terlebih dahulu di pengaturan</string>
<string name="external_player_unavailable">Tidak ada player eksternal yang tersedia</string>
<string name="streams_torrent_not_supported">Jenis stream ini tidak didukung</string>
<string name="trailer_close">Tutup trailer</string>
<string name="trailer_unable_to_play">Tidak dapat memutar trailer</string>
<string name="trakt_lists_load_failed">Gagal memuat daftar Trakt</string>

View file

@ -18,6 +18,7 @@
<string name="action_resume">Resume</string>
<string name="action_retry">Retry</string>
<string name="action_save">Save</string>
<string name="action_validate">Validate</string>
<string name="addon_installing">Installing</string>
<string name="addon_title">Addons</string>
<string name="addons_badge_active">Active</string>
@ -247,6 +248,19 @@
<string name="collections_editor_tmdb_sort_popular">Popular</string>
<string name="collections_editor_tmdb_sort_top_rated">Top Rated</string>
<string name="collections_editor_tmdb_sort_recent">Recent</string>
<string name="collections_editor_tmdb_sort_vote_count">Most Voted</string>
<string name="collections_editor_tmdb_watch_region">Watch region</string>
<string name="collections_editor_tmdb_watch_region_helper">ISO 3166-1 country code where the title is available. Example: US, GB.</string>
<string name="collections_editor_tmdb_quick_watch_regions">Quick watch regions</string>
<string name="collections_editor_tmdb_watch_providers">Watch provider IDs</string>
<string name="collections_editor_tmdb_watch_providers_helper">Use TMDB watch provider IDs. Separate multiple with commas for AND, or pipes for OR.</string>
<string name="collections_editor_tmdb_watch_providers_placeholder">8|337|350</string>
<string name="collections_editor_tmdb_quick_watch_providers">Quick watch providers</string>
<string name="collections_editor_tmdb_watch_provider_netflix">Netflix</string>
<string name="collections_editor_tmdb_watch_provider_prime">Prime Video</string>
<string name="collections_editor_tmdb_watch_provider_disney">Disney+</string>
<string name="collections_editor_tmdb_watch_provider_apple">Apple TV+</string>
<string name="collections_editor_tmdb_watch_provider_hulu">Hulu</string>
<string name="collections_editor_tmdb_subtitle_list">TMDB List</string>
<string name="collections_editor_tmdb_subtitle_movie_collection">TMDB Movie Collection</string>
<string name="collections_editor_tmdb_subtitle_production">Production</string>
@ -365,6 +379,7 @@
<string name="compose_settings_page_appearance">Layout</string>
<string name="compose_settings_page_content_discovery">Content &amp; Discovery</string>
<string name="compose_settings_page_continue_watching">Continue Watching</string>
<string name="compose_settings_page_debrid">Debrid</string>
<string name="compose_settings_page_homescreen">Home Layout</string>
<string name="compose_settings_page_integrations">Integrations</string>
<string name="compose_settings_page_licenses_attributions">Licenses &amp; Attribution</string>
@ -573,6 +588,26 @@
<string name="settings_integrations_section_title">Integrations</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_debrid_description">Cloud account sources</string>
<string name="settings_debrid_section_title">Debrid</string>
<string name="settings_debrid_enable">Enable sources</string>
<string name="settings_debrid_enable_description">Show playable results from connected accounts.</string>
<string name="settings_debrid_add_key_first">Add an API key first.</string>
<string name="settings_debrid_section_providers">Account</string>
<string name="settings_debrid_provider_torbox_description">Connect your Torbox account.</string>
<string name="settings_debrid_section_instant_playback">Instant Playback</string>
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
<string name="settings_debrid_prepare_instant_playback_description">Resolve the first sources before playback starts.</string>
<string name="settings_debrid_prepare_stream_count">Sources to prepare</string>
<string name="settings_debrid_prepare_count_one">1 source</string>
<string name="settings_debrid_prepare_count_many">%1$d sources</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 source 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 source.</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_api_key_description">Required to fetch ratings from MDBList</string>
<string name="settings_mdb_api_key_label">API Key</string>
@ -1107,7 +1142,10 @@
<string name="streams_resume_from_percent">Resume from %1$d%</string>
<string name="streams_resume_from_time">Resume from %1$s</string>
<string name="streams_size">SIZE %1$s</string>
<string name="streams_torrent_not_supported">Torrent streams are not supported</string>
<string name="streams_torrent_not_supported">This stream type is not supported</string>
<string name="debrid_missing_api_key">Add a Debrid API key in Settings.</string>
<string name="debrid_stream_stale">This Debrid result expired. Refreshing streams.</string>
<string name="debrid_resolve_failed">Could not resolve this Debrid stream.</string>
<string name="external_player_failed">Couldn&apos;t open external player</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>

View file

@ -106,6 +106,9 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
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.DownloadsScreen
import com.nuvio.app.features.details.MetaDetailsRepository
@ -159,6 +162,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
import com.nuvio.app.features.collection.FolderDetailScreen
import com.nuvio.app.features.collection.FolderDetailRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLaunch
import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamLinkCacheRepository
@ -467,6 +471,11 @@ fun App() {
AuthScreen(modifier = Modifier.fillMaxSize())
}
AppGateScreen.ProfileSelection.name -> {
PlatformBackHandler(enabled = gateScreen == AppGateScreen.ProfileSelection.name) {
if (!autoSkipProfileSelection) {
gateScreen = AppGateScreen.Main.name
}
}
ProfileSelectionScreen(
onProfileSelected = { profile ->
ProfileRepository.selectProfile(profile.profileIndex)
@ -489,6 +498,9 @@ fun App() {
)
}
AppGateScreen.ProfileEdit.name -> {
PlatformBackHandler(enabled = gateScreen == AppGateScreen.ProfileEdit.name) {
gateScreen = AppGateScreen.ProfileSelection.name
}
ProfileEditScreen(
profile = editingProfile,
onBack = { gateScreen = AppGateScreen.ProfileSelection.name },
@ -1316,6 +1328,8 @@ private fun MainAppContent(
return@composable
}
val pauseDescription = launch.pauseDescription
val streamRouteScope = rememberCoroutineScope()
var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) }
val lifecycleOwner = backStackEntry
DisposableEffect(lifecycleOwner, route.launchId) {
val observer = LifecycleEventObserver { _, event ->
@ -1465,7 +1479,30 @@ private fun MainAppContent(
if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val stream = when (
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = selectedStream,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
) {
is DirectDebridPlayableResult.Success -> resolved.stream
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
StreamsRepository.consumeAutoPlay()
if (resolved == DirectDebridPlayableResult.Stale) {
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
}
return@LaunchedEffect
}
}
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
@ -1537,12 +1574,46 @@ private fun MainAppContent(
}
fun openSelectedStream(
stream: com.nuvio.app.features.streams.StreamItem,
stream: StreamItem,
resolvedResumePositionMs: Long?,
resolvedResumeProgressFraction: Float?,
forceExternal: Boolean,
forceInternal: Boolean,
) {
if (stream.isDirectDebridStream && stream.directPlaybackUrl == null) {
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,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
}
}
}
}
return
}
val sourceUrl = stream.directPlaybackUrl ?: return
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
@ -1604,47 +1675,69 @@ private fun MainAppContent(
)
}
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
)
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
},
onBack = {
StreamsRepository.clear()
navController.popBackStack()
},
modifier = Modifier.fillMaxSize(),
)
Box(modifier = Modifier.fillMaxSize()) {
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
)
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
},
onBack = {
StreamsRepository.clear()
navController.popBackStack()
},
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>(
enterTransition = {

View file

@ -6,6 +6,8 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
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.MetaScreenSettingsRepository
import com.nuvio.app.features.mdblist.MdbListMetadataService
@ -157,6 +159,7 @@ object ProfileSettingsSync {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" },
DebridSettingsRepository.uiState.map { "debrid" },
TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
@ -202,6 +205,7 @@ object ProfileSettingsSync {
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
@ -226,6 +230,9 @@ object ProfileSettingsSync {
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
PlayerSettingsRepository.onProfileChanged()
DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings)
DebridSettingsRepository.onProfileChanged()
TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings)
TmdbSettingsRepository.onProfileChanged()
@ -255,6 +262,7 @@ object ProfileSettingsSync {
ThemeSettingsRepository.ensureLoaded()
PosterCardStyleRepository.ensureLoaded()
PlayerSettingsRepository.ensureLoaded()
DebridSettingsRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
@ -277,6 +285,7 @@ object ProfileSettingsSync {
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}",
"debrid=${DebridSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
@ -299,6 +308,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
@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("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",

View file

@ -1125,6 +1125,7 @@ private fun TmdbSourcePickerScreen(
val sorts = listOf(
TmdbCollectionSort.POPULAR_DESC,
TmdbCollectionSort.VOTE_AVERAGE_DESC,
TmdbCollectionSort.VOTE_COUNT_DESC,
if (state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth) {
TmdbCollectionSort.FIRST_AIR_DATE_DESC
} else {
@ -1353,6 +1354,54 @@ private fun TmdbSourcePickerScreen(
}
},
)
TmdbQuickChips(
label = stringResource(Res.string.collections_editor_tmdb_quick_watch_providers),
chips = listOf(
stringResource(Res.string.collections_editor_tmdb_watch_provider_netflix) to "8",
stringResource(Res.string.collections_editor_tmdb_watch_provider_prime) to "119",
stringResource(Res.string.collections_editor_tmdb_watch_provider_disney) to "337",
stringResource(Res.string.collections_editor_tmdb_watch_provider_apple) to "350",
stringResource(Res.string.collections_editor_tmdb_watch_provider_hulu) to "15",
),
onSelect = { value ->
CollectionEditorRepository.updateTmdbFilters { it.copy(withWatchProviders = value) }
},
)
TmdbFilterField(
label = stringResource(Res.string.collections_editor_tmdb_watch_providers),
helper = stringResource(Res.string.collections_editor_tmdb_watch_providers_helper),
value = state.tmdbFilters.withWatchProviders.orEmpty(),
placeholder = stringResource(Res.string.collections_editor_tmdb_watch_providers_placeholder),
onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters {
it.copy(withWatchProviders = value.ifBlank { null })
}
},
)
TmdbQuickChips(
label = stringResource(Res.string.collections_editor_tmdb_quick_watch_regions),
chips = listOf(
stringResource(Res.string.collections_editor_tmdb_country_us) to "US",
stringResource(Res.string.collections_editor_tmdb_country_uk) to "GB",
"Canada" to "CA",
"Australia" to "AU",
"Germany" to "DE",
),
onSelect = { value ->
CollectionEditorRepository.updateTmdbFilters { it.copy(watchRegion = value) }
},
)
TmdbFilterField(
label = stringResource(Res.string.collections_editor_tmdb_watch_region),
helper = stringResource(Res.string.collections_editor_tmdb_watch_region_helper),
value = state.tmdbFilters.watchRegion.orEmpty(),
placeholder = "US",
onValueChange = { value ->
CollectionEditorRepository.updateTmdbFilters {
it.copy(watchRegion = value.ifBlank { null })
}
},
)
}
}
}
@ -2255,6 +2304,7 @@ private fun tmdbSortLabel(sort: TmdbCollectionSort): String =
TmdbCollectionSort.ORIGINAL -> stringResource(Res.string.collections_editor_tmdb_sort_original)
TmdbCollectionSort.POPULAR_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_popular)
TmdbCollectionSort.VOTE_AVERAGE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_top_rated)
TmdbCollectionSort.VOTE_COUNT_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_vote_count)
TmdbCollectionSort.RELEASE_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
}

View file

@ -100,6 +100,7 @@ enum class TmdbCollectionSort(val value: String) {
ORIGINAL("original"),
POPULAR_DESC("popularity.desc"),
VOTE_AVERAGE_DESC("vote_average.desc"),
VOTE_COUNT_DESC("vote_count.desc"),
RELEASE_DATE_DESC("primary_release_date.desc"),
FIRST_AIR_DATE_DESC("first_air_date.desc"),
}
@ -149,6 +150,8 @@ data class TmdbCollectionFilters(
val withCompanies: String? = null,
val withNetworks: String? = null,
val year: Int? = null,
val watchRegion: String? = null,
val withWatchProviders: String? = null,
)
data class TmdbSourceImportMetadata(

View file

@ -325,6 +325,11 @@ object TmdbCollectionSourceResolver {
putIfNotBlank("with_original_language", filters.withOriginalLanguage)
putIfNotBlank("with_origin_country", filters.withOriginCountry)
putIfNotBlank("with_keywords", filters.withKeywords)
if (!filters.withWatchProviders.isNullOrBlank()) {
put("with_watch_providers", filters.withWatchProviders)
put("watch_region", filters.watchRegion?.takeIf { it.isNotBlank() } ?: "US")
put("with_watch_monetization_types", "flatrate|free|ads|rent|buy")
}
putIfNotBlank("year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.MOVIE }?.toString())
putIfNotBlank("first_air_date_year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.TV }?.toString())
putIfNotBlank(
@ -358,6 +363,7 @@ object TmdbCollectionSourceResolver {
compareByDescending<MetaPreview> { it.imdbRating?.toDoubleOrNull() ?: -1.0 }
.thenByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() },
)
TmdbCollectionSort.VOTE_COUNT_DESC.value -> sortedByDescending { it.voteCount ?: 0 }
TmdbCollectionSort.RELEASE_DATE_DESC.value,
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> sortedByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() }
TmdbCollectionSort.POPULAR_DESC.value,
@ -395,6 +401,7 @@ object TmdbCollectionSourceResolver {
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
voteCount = voteCount,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
@ -412,6 +419,7 @@ object TmdbCollectionSourceResolver {
releaseInfo = releaseDate?.take(4),
rawReleaseDate = releaseDate,
popularity = popularity,
voteCount = voteCount,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
@ -440,6 +448,7 @@ object TmdbCollectionSourceResolver {
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
voteCount = voteCount,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
@ -468,6 +477,7 @@ object TmdbCollectionSourceResolver {
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
voteCount = voteCount,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
@ -508,6 +518,7 @@ object TmdbCollectionSourceResolver {
when (sortBy) {
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
TmdbCollectionSort.VOTE_COUNT_DESC.value -> TmdbCollectionSort.VOTE_COUNT_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy
}
@ -516,6 +527,7 @@ object TmdbCollectionSourceResolver {
when (sortBy) {
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
TmdbCollectionSort.VOTE_COUNT_DESC.value -> TmdbCollectionSort.VOTE_COUNT_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy
}
@ -640,6 +652,7 @@ private data class TmdbPersonCreditCast(
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
@SerialName("vote_count") val voteCount: Int? = null,
val popularity: Double? = null,
)
@ -658,6 +671,7 @@ private data class TmdbPersonCreditCrew(
@SerialName("first_air_date") val firstAirDate: String? = null,
val job: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
@SerialName("vote_count") val voteCount: Int? = null,
val popularity: Double? = null,
)
@ -675,6 +689,7 @@ private data class TmdbListItem(
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
@SerialName("vote_count") val voteCount: Int? = null,
val popularity: Double? = null,
)
@ -687,5 +702,6 @@ private data class TmdbCollectionPart(
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
@SerialName("vote_count") val voteCount: Int? = null,
val popularity: Double? = null,
)

View file

@ -0,0 +1,244 @@
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.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 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 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 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,
)
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 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")
}
object DebridCredentialValidator {
suspend fun validateProvider(providerId: String, apiKey: String): Boolean {
val normalized = apiKey.trim()
if (normalized.isBlank()) return false
return when (DebridProviders.byId(providerId)?.id) {
DebridProviders.TORBOX_ID -> TorboxApiClient.validateApiKey(normalized)
DebridProviders.REAL_DEBRID_ID -> RealDebridApiClient.validateApiKey(normalized)
else -> false
}
}
}
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")
}

View file

@ -0,0 +1,94 @@
package com.nuvio.app.features.debrid
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@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 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,
)

View file

@ -0,0 +1,169 @@
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()
}
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",
)

View file

@ -0,0 +1,83 @@
package com.nuvio.app.features.debrid
data class DebridProvider(
val id: String,
val displayName: String,
val shortName: String,
val visibleInUi: Boolean = true,
)
data class DebridServiceCredential(
val provider: DebridProvider,
val apiKey: String,
)
object DebridProviders {
const val TORBOX_ID = "torbox"
const val REAL_DEBRID_ID = "realdebrid"
val Torbox = DebridProvider(
id = TORBOX_ID,
displayName = "Torbox",
shortName = "TB",
)
val RealDebrid = DebridProvider(
id = REAL_DEBRID_ID,
displayName = "Real-Debrid",
shortName = "RD",
visibleInUi = false,
)
private val registered = listOf(Torbox, 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> =
buildList {
settings.torboxApiKey.trim().takeIf { Torbox.visibleInUi && it.isNotBlank() }?.let { apiKey ->
add(DebridServiceCredential(Torbox, apiKey))
}
settings.realDebridApiKey.trim().takeIf { RealDebrid.visibleInUi && it.isNotBlank() }?.let { apiKey ->
add(DebridServiceCredential(RealDebrid, apiKey))
}
}
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" }
}
}

View file

@ -0,0 +1,19 @@
package com.nuvio.app.features.debrid
data class DebridSettings(
val enabled: Boolean = false,
val torboxApiKey: String = "",
val realDebridApiKey: String = "",
val instantPlaybackPreparationLimit: Int = 0,
val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
) {
val hasAnyApiKey: Boolean
get() = DebridProviders.configuredServices(this).isNotEmpty()
}
internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2
internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5
internal fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int =
value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT)

View file

@ -0,0 +1,136 @@
package com.nuvio.app.features.debrid
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object DebridSettingsRepository {
private val _uiState = MutableStateFlow(DebridSettings())
val uiState: StateFlow<DebridSettings> = _uiState.asStateFlow()
private var hasLoaded = false
private var enabled = false
private var torboxApiKey = ""
private var realDebridApiKey = ""
private var instantPlaybackPreparationLimit = 0
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 && !hasVisibleApiKey()) return
if (enabled == value) return
enabled = value
publish()
DebridSettingsStorage.saveEnabled(value)
}
fun setTorboxApiKey(value: String) {
ensureLoaded()
val normalized = value.trim()
if (torboxApiKey == normalized) return
torboxApiKey = normalized
disableIfNoKeys()
publish()
DebridSettingsStorage.saveTorboxApiKey(normalized)
}
fun setRealDebridApiKey(value: String) {
ensureLoaded()
val normalized = value.trim()
if (realDebridApiKey == normalized) return
realDebridApiKey = normalized
disableIfNoKeys()
publish()
DebridSettingsStorage.saveRealDebridApiKey(normalized)
}
fun setInstantPlaybackPreparationLimit(value: Int) {
ensureLoaded()
val normalized = normalizeDebridInstantPlaybackPreparationLimit(value)
if (instantPlaybackPreparationLimit == normalized) return
instantPlaybackPreparationLimit = normalized
publish()
DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized)
}
fun setStreamNameTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
if (streamNameTemplate == normalized) return
streamNameTemplate = normalized
publish()
DebridSettingsStorage.saveStreamNameTemplate(normalized)
}
fun setStreamDescriptionTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
if (streamDescriptionTemplate == normalized) return
streamDescriptionTemplate = normalized
publish()
DebridSettingsStorage.saveStreamDescriptionTemplate(normalized)
}
fun resetStreamTemplates() {
ensureLoaded()
streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
publish()
DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate)
DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate)
}
private fun disableIfNoKeys() {
if (!hasVisibleApiKey()) {
enabled = false
DebridSettingsStorage.saveEnabled(false)
}
}
private fun hasVisibleApiKey(): Boolean =
(DebridProviders.isVisible(DebridProviders.TORBOX_ID) && torboxApiKey.isNotBlank()) ||
(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID) && realDebridApiKey.isNotBlank())
private fun loadFromDisk() {
hasLoaded = true
torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty()
realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty()
enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey()
instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
)
streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.NAME_TEMPLATE
streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
publish()
}
private fun publish() {
_uiState.value = DebridSettings(
enabled = enabled,
torboxApiKey = torboxApiKey,
realDebridApiKey = realDebridApiKey,
instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
streamNameTemplate = streamNameTemplate,
streamDescriptionTemplate = streamDescriptionTemplate,
)
}
}

View file

@ -0,0 +1,20 @@
package com.nuvio.app.features.debrid
import kotlinx.serialization.json.JsonObject
internal expect object DebridSettingsStorage {
fun loadEnabled(): Boolean?
fun saveEnabled(enabled: Boolean)
fun loadTorboxApiKey(): String?
fun saveTorboxApiKey(apiKey: String)
fun loadRealDebridApiKey(): String?
fun saveRealDebridApiKey(apiKey: String)
fun loadInstantPlaybackPreparationLimit(): Int?
fun saveInstantPlaybackPreparationLimit(limit: Int)
fun loadStreamNameTemplate(): String?
fun saveStreamNameTemplate(template: String)
fun loadStreamDescriptionTemplate(): String?
fun saveStreamDescriptionTemplate(template: String)
fun exportToSyncPayload(): JsonObject
fun replaceFromSyncPayload(payload: JsonObject)
}

View file

@ -0,0 +1,143 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamClientResolveParsed
import com.nuvio.app.features.streams.StreamItem
class DebridStreamFormatter(
private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(),
) {
fun format(stream: StreamItem, settings: DebridSettings): StreamItem {
if (!stream.isDirectDebridStream) return stream
val values = buildValues(stream)
val formattedName = engine.render(settings.streamNameTemplate, values)
.lineSequence()
.joinToString(" ") { it.trim() }
.replace(Regex("\\s+"), " ")
.trim()
val formattedDescription = engine.render(settings.streamDescriptionTemplate, values)
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.joinToString("\n")
.trim()
return stream.copy(
name = formattedName.ifBlank { stream.name ?: DebridProviders.instantName(stream.clientResolve?.service) },
description = formattedDescription.ifBlank { stream.description ?: stream.title },
)
}
private fun buildValues(stream: StreamItem): Map<String, Any?> {
val resolve = stream.clientResolve
val raw = resolve?.stream?.raw
val parsed = raw?.parsed
val seasons = parsed?.seasons.orEmpty()
val episodes = parsed?.episodes.orEmpty()
val season = resolve?.season ?: seasons.singleOrFirstOrNull()
val episode = resolve?.episode ?: episodes.singleOrFirstOrNull()
val visualTags = buildList {
addAll(parsed?.hdr.orEmpty())
parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) }
}
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 parsed?.resolution,
"stream.library" to false,
"stream.quality" to parsed?.quality,
"stream.visualTags" to visualTags,
"stream.audioTags" to parsed?.audio.orEmpty(),
"stream.audioChannels" to parsed?.channels.orEmpty(),
"stream.languages" to parsed?.languages.orEmpty(),
"stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) },
"stream.size" to (raw?.size ?: stream.behaviorHints.videoSize)?.let(::DebridTemplateBytes),
"stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes),
"stream.encode" to parsed?.codec?.uppercase(),
"stream.indexer" to (raw?.indexer ?: raw?.tracker),
"stream.network" to (parsed?.network ?: raw?.network),
"stream.releaseGroup" to parsed?.group,
"stream.duration" to parsed?.duration,
"stream.edition" to edition,
"stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename),
"stream.regexMatched" to null,
"stream.type" to streamType(resolve),
"service.cached" to resolve?.isCached,
"service.shortName" to serviceShortName(resolve),
"service.name" to serviceName(resolve),
"addon.name" to "Nuvio Direct Debrid",
)
}
private fun streamType(resolve: StreamClientResolve?): String =
when {
resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid"
resolve?.type.equals("torrent", ignoreCase = true) -> "p2p"
else -> resolve?.type.orEmpty()
}
private fun serviceShortName(resolve: StreamClientResolve?): String =
resolve?.serviceExtension?.takeIf { it.isNotBlank() }
?: DebridProviders.shortName(resolve?.service)
private fun serviceName(resolve: StreamClientResolve?): String =
DebridProviders.displayName(resolve?.service)
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 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
}
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.features.debrid
object DebridStreamFormatterDefaults {
const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{stream.resolution::exists[\"\"||\"Direct \"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}Instant"
const val 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}\"||\"\"]}"
}

View file

@ -0,0 +1,394 @@
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"
}
}
}

View file

@ -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])
}
}
}
}

View file

@ -0,0 +1,39 @@
package com.nuvio.app.features.debrid
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
class DirectDebridConfigEncoder {
@OptIn(ExperimentalEncodingApi::class)
fun encode(service: DebridServiceCredential): String {
val servicesJson = """{"service":"${service.provider.id.jsonEscaped()}","apiKey":"${service.apiKey.jsonEscaped()}"}"""
val json = """{"cachedOnly":true,"debridServices":[$servicesJson],"enableTorrent":false}"""
return Base64.Default.encode(json.encodeToByteArray())
}
fun encodeTorbox(apiKey: String): String =
encode(DebridServiceCredential(DebridProviders.Torbox, apiKey))
}
private fun String.jsonEscaped(): String = buildString {
this@jsonEscaped.forEach { char ->
when (char) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\b' -> append("\\b")
'\u000C' -> append("\\f")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> {
if (char.code < 0x20) {
append("\\u")
append(char.code.toString(16).padStart(4, '0'))
} else {
append(char)
}
}
}
}
}

View file

@ -0,0 +1,375 @@
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.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_resolve_failed
import nuvio.composeapp.generated.resources.debrid_stream_stale
import org.jetbrains.compose.resources.getString
object DirectDebridPlaybackResolver {
private val torboxResolver = TorboxDirectDebridResolver()
private val realDebridResolver = RealDebridDirectDebridResolver()
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 {
val cacheKey = stream.directDebridResolveCacheKey(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? {
val cacheKey = stream.directDebridResolveCacheKey(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..DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS) {
cached.result
} else {
resolvedCache.remove(cacheKey)
null
}
}
private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult =
when (DebridProviders.byId(stream.clientResolve?.service)?.id) {
DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode)
DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode)
else -> DirectDebridResolveResult.Error
}
suspend fun resolveToPlayableStream(
stream: StreamItem,
season: Int?,
episode: Int?,
): DirectDebridPlayableResult {
if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) {
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.Stale -> DirectDebridPlayableResult.Stale
DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error
}
}
}
private const val DIRECT_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 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 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.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) }
DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) }
}
private class TorboxDirectDebridResolver(
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
?: buildMagnetUri(resolve)
?: run {
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)
?: run {
return DirectDebridResolveResult.Stale
}
val fileId = file.id
?: run {
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() }
?: run {
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
}
}
private fun DebridApiResponse<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.toFailureForCreate(): DirectDebridResolveResult =
when (status) {
401, 403 -> DirectDebridResolveResult.Error
else -> DirectDebridResolveResult.Stale
}
}
private class RealDebridDirectDebridResolver(
private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot().realDebridApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
?: buildMagnetUri(resolve)
?: run {
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,
)
?: run {
return DirectDebridResolveResult.Stale
}
val fileId = file.id
?: run {
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()
?: run {
return DirectDebridResolveResult.Stale
}
val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link)
if (!unrestrict.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val url = unrestrict.body?.download?.takeIf { it.isNotBlank() }
?: run {
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 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() }
}
}
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
.filter { it.isNotBlank() }
.forEach { source ->
append("&tr=")
append(encodePathSegment(source))
}
}
}
private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): String? {
val resolve = clientResolve ?: return null
val providerId = DebridProviders.byId(resolve.service)?.id ?: return null
val apiKey = when (providerId) {
DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey
DebridProviders.REAL_DEBRID_ID -> DebridSettingsRepository.snapshot().realDebridApiKey
else -> ""
}.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 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,
)

View file

@ -0,0 +1,41 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamItem
object DirectDebridStreamFilter {
const val FALLBACK_SOURCE_NAME = "Direct Debrid"
fun filterInstant(streams: List<StreamItem>): List<StreamItem> =
streams
.filter(::isInstantCandidate)
.map { stream ->
val providerId = stream.clientResolve?.service
val sourceName = DebridProviders.instantName(providerId)
stream.copy(
name = stream.name ?: sourceName,
addonName = sourceName,
addonId = DebridProviders.addonId(providerId),
sourceName = stream.sourceName ?: FALLBACK_SOURCE_NAME,
)
}
.distinctBy { stream ->
listOf(
stream.clientResolve?.infoHash?.lowercase(),
stream.clientResolve?.fileIdx?.toString(),
stream.clientResolve?.filename,
stream.name,
stream.title,
).joinToString("|")
}
fun isInstantCandidate(stream: StreamItem): Boolean {
val resolve = stream.clientResolve ?: return false
return resolve.type.equals("debrid", ignoreCase = true) &&
DebridProviders.isSupported(resolve.service) &&
resolve.isCached == true
}
fun isDirectDebridSourceName(addonName: String): Boolean =
DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) }
}

View file

@ -0,0 +1,196 @@
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.enabled || limit <= 0 || !settings.hasAnyApiKey) return
val candidates = prioritizeCandidates(
streams = streams,
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 Torbox budget reached" }
return
}
try {
when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) {
is DirectDebridPlayableResult.Success -> {
if (result.stream.directPlaybackUrl != 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 { it.isDirectDebridStream && it.directPlaybackUrl == null }
.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?.isDirectDebridStream == 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,
): List<AddonStreamGroup> {
val key = original.preparationKey()
return groups.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(),
directPlaybackUrl.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(directPlaybackUrl.orEmpty())
}

View file

@ -0,0 +1,96 @@
package com.nuvio.app.features.debrid
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamParser
import kotlinx.coroutines.CancellationException
private const val DIRECT_DEBRID_TAG = "DirectDebridStreams"
data class DirectDebridStreamTarget(
val provider: DebridProvider,
val apiKey: String,
) {
val addonId: String = DebridProviders.addonId(provider.id)
val addonName: String = DebridProviders.instantName(provider.id)
}
object DirectDebridStreamSource {
private val log = Logger.withTag(DIRECT_DEBRID_TAG)
private val encoder = DirectDebridConfigEncoder()
private val formatter = DebridStreamFormatter()
fun configuredTargets(): List<DirectDebridStreamTarget> {
DebridSettingsRepository.ensureLoaded()
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled || DebridConfig.DIRECT_DEBRID_API_BASE_URL.isBlank()) return emptyList()
return DebridProviders.configuredServices(settings).map { credential ->
DirectDebridStreamTarget(
provider = credential.provider,
apiKey = credential.apiKey,
)
}
}
fun placeholders(): List<AddonStreamGroup> =
configuredTargets().map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}
suspend fun fetchProviderStreams(
type: String,
videoId: String,
target: DirectDebridStreamTarget,
): AddonStreamGroup {
val settings = DebridSettingsRepository.snapshot()
val baseUrl = DebridConfig.DIRECT_DEBRID_API_BASE_URL.trim().trimEnd('/')
if (!settings.enabled || baseUrl.isBlank()) {
return target.emptyGroup()
}
val credential = DebridServiceCredential(target.provider, target.apiKey)
val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json"
return try {
val payload = httpGetText(url)
val streams = StreamParser.parse(
payload = payload,
addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME,
addonId = target.addonId,
)
.let(DirectDebridStreamFilter::filterInstant)
.filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) }
.map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) }
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = streams,
isLoading = false,
)
} catch (error: Exception) {
if (error is CancellationException) throw error
log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" }
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = false,
error = error.message,
)
}
}
private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup =
AddonStreamGroup(
addonName = addonName,
addonId = addonId,
streams = emptyList(),
isLoading = false,
)
}

View file

@ -80,10 +80,12 @@ fun DetailMetaInfo(
val runtimeText = formatRuntimeForDisplay(meta.runtime)
val ageBadge = meta.ageRating?.trim()?.takeIf { it.isNotBlank() }
val hasMdbImdbRating = meta.externalRatings.any { it.source == PROVIDER_IMDB }
val validImdbRating = meta.imdbRating
?.takeIf { raw -> raw.toDoubleOrNull()?.let { it > 0.0 } == true }
val hasMetaRow = releaseLine != null ||
runtimeText != null ||
ageBadge != null ||
(meta.imdbRating != null && !hasMdbImdbRating)
(validImdbRating != null && !hasMdbImdbRating)
if (hasMetaRow) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -108,7 +110,7 @@ fun DetailMetaInfo(
ageBadge?.let { badge ->
DetailHeroMetaBadge(text = badge)
}
if (meta.imdbRating != null && !hasMdbImdbRating) {
if (validImdbRating != null && !hasMdbImdbRating) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
@ -129,7 +131,7 @@ fun DetailMetaInfo(
}
Spacer(modifier = Modifier.width(5.dp))
Text(
text = meta.imdbRating,
text = validImdbRating,
style = MaterialTheme.typography.titleMedium,
color = ImdbYellow,
fontWeight = FontWeight.Bold,

View file

@ -15,6 +15,7 @@ data class MetaPreview(
val releaseInfo: String? = null,
val rawReleaseDate: String? = null,
val popularity: Double? = null,
val voteCount: Int? = null,
val imdbRating: String? = null,
val genres: List<String> = emptyList(),
)

View file

@ -597,7 +597,7 @@ private fun EpisodeStreamsSubView(
) {
itemsIndexed(
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 ->
EpisodeSourceStreamRow(
stream = stream,

View file

@ -38,6 +38,10 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioToastController
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.AddonResource
import com.nuvio.app.features.addons.ManagedAddon
@ -802,7 +806,55 @@ fun PlayerScreen(
playerController?.seekTo(targetPositionMs)
}
fun resolveDebridForPlayer(
stream: StreamItem,
season: Int?,
episode: Int?,
onResolved: (StreamItem) -> Unit,
onStale: () -> Unit,
): Boolean {
if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) 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) {
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.directPlaybackUrl ?: return
if (url == activeSourceUrl) return
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
@ -844,6 +896,26 @@ fun PlayerScreen(
}
fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
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.directPlaybackUrl ?: return
showNextEpisodeCard = false
showSourcesPanel = false

View file

@ -203,7 +203,7 @@ fun PlayerSourcesPanel(
) {
itemsIndexed(
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 ->
val isCurrent = isCurrentStream(
stream = stream,

View file

@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.pluginContentId
@ -152,6 +154,10 @@ object PlayerStreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value
val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.getEnabledScrapersForType(type)
@ -159,7 +165,7 @@ object PlayerStreamsRepository {
emptyList()
}
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
@ -185,7 +191,7 @@ object PlayerStreamsRepository {
)
}
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
@ -207,6 +213,13 @@ object PlayerStreamsRepository {
streams = emptyList(),
isLoading = true,
)
} + debridTargets.map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}
stateFlow.value = StreamsUiState(
groups = initialGroups,
@ -275,7 +288,18 @@ object PlayerStreamsRepository {
}
}
val jobs = addonJobs + pluginJobs
val debridJobs = debridTargets.map { target ->
async {
DirectDebridStreamSource.fetchProviderStreams(
type = type,
videoId = videoId,
target = target,
)
}
}
val jobs = addonJobs + pluginJobs + debridJobs
var debridPreparationLaunched = false
jobs.forEach { deferred ->
val result = deferred.await()
stateFlow.update { current ->
@ -293,6 +317,28 @@ object PlayerStreamsRepository {
} else null,
)
}
if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) {
debridPreparationLaunched = true
launch {
DirectDebridStreamPreparer.prepare(
streams = stateFlow.value.groups.flatMap { it.streams },
season = season,
episode = episode,
playerSettings = playerSettings,
installedAddonNames = installedAddonNames,
) { original, prepared ->
stateFlow.update { current ->
current.copy(
groups = DirectDebridStreamPreparer.replacePreparedStream(
groups = current.groups,
original = original,
prepared = prepared,
),
)
}
}
}
}
}
}
setJob(job)

View file

@ -0,0 +1,443 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT
import com.nuvio.app.features.debrid.DebridCredentialValidator
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.DebridSettings
import com.nuvio.app.features.debrid.DebridSettingsRepository
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_reset
import nuvio.composeapp.generated.resources.action_save
import nuvio.composeapp.generated.resources.action_validate
import nuvio.composeapp.generated.resources.settings_debrid_add_key_first
import nuvio.composeapp.generated.resources.settings_debrid_description_template
import nuvio.composeapp.generated.resources.settings_debrid_description_template_description
import nuvio.composeapp.generated.resources.settings_debrid_enable
import nuvio.composeapp.generated.resources.settings_debrid_enable_description
import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many
import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one
import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback
import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description
import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count
import nuvio.composeapp.generated.resources.settings_debrid_key_valid
import nuvio.composeapp.generated.resources.settings_debrid_key_invalid
import nuvio.composeapp.generated.resources.settings_debrid_name_template
import nuvio.composeapp.generated.resources.settings_debrid_name_template_description
import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description
import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback
import nuvio.composeapp.generated.resources.settings_debrid_section_formatting
import nuvio.composeapp.generated.resources.settings_debrid_section_providers
import nuvio.composeapp.generated.resources.settings_debrid_section_title
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.debridSettingsContent(
isTablet: Boolean,
settings: DebridSettings,
) {
item {
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_title),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = stringResource(Res.string.settings_debrid_enable),
description = stringResource(Res.string.settings_debrid_enable_description),
checked = settings.enabled,
enabled = settings.hasAnyApiKey,
isTablet = isTablet,
onCheckedChange = DebridSettingsRepository::setEnabled,
)
if (!settings.hasAnyApiKey) {
SettingsGroupDivider(isTablet = isTablet)
DebridInfoRow(
isTablet = isTablet,
text = stringResource(Res.string.settings_debrid_add_key_first),
)
}
}
}
}
item {
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_providers),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
DebridApiKeyRow(
isTablet = isTablet,
providerId = DebridProviders.TORBOX_ID,
title = DebridProviders.Torbox.displayName,
description = stringResource(Res.string.settings_debrid_provider_torbox_description),
value = settings.torboxApiKey,
onApiKeyCommitted = DebridSettingsRepository::setTorboxApiKey,
)
}
}
}
item {
var showPrepareCountDialog by rememberSaveable { mutableStateOf(false) }
val prepareLimit = settings.instantPlaybackPreparationLimit
val prepareEnabled = settings.enabled && prepareLimit > 0
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_instant_playback),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = stringResource(Res.string.settings_debrid_prepare_instant_playback),
description = stringResource(Res.string.settings_debrid_prepare_instant_playback_description),
checked = prepareEnabled,
enabled = settings.enabled && settings.hasAnyApiKey,
isTablet = isTablet,
onCheckedChange = { enabled ->
DebridSettingsRepository.setInstantPlaybackPreparationLimit(
if (enabled) DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT else 0,
)
},
)
if (prepareEnabled) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_debrid_prepare_stream_count),
description = prepareCountLabel(prepareLimit),
isTablet = isTablet,
onClick = { showPrepareCountDialog = true },
)
}
}
}
if (showPrepareCountDialog) {
DebridPrepareCountDialog(
selectedLimit = prepareLimit,
onLimitSelected = { limit ->
DebridSettingsRepository.setInstantPlaybackPreparationLimit(limit)
showPrepareCountDialog = false
},
onDismiss = { showPrepareCountDialog = false },
)
}
}
item {
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_formatting),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
DebridTemplateRow(
isTablet = isTablet,
title = stringResource(Res.string.settings_debrid_name_template),
description = stringResource(Res.string.settings_debrid_name_template_description),
value = settings.streamNameTemplate,
singleLine = true,
onTemplateCommitted = DebridSettingsRepository::setStreamNameTemplate,
)
SettingsGroupDivider(isTablet = isTablet)
DebridTemplateRow(
isTablet = isTablet,
title = stringResource(Res.string.settings_debrid_description_template),
description = stringResource(Res.string.settings_debrid_description_template_description),
value = settings.streamDescriptionTemplate,
singleLine = false,
onTemplateCommitted = DebridSettingsRepository::setStreamDescriptionTemplate,
)
SettingsGroupDivider(isTablet = isTablet)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 12.dp),
) {
TextButton(onClick = DebridSettingsRepository::resetStreamTemplates) {
Text(stringResource(Res.string.action_reset))
}
}
}
}
}
}
@Composable
private fun prepareCountLabel(limit: Int): String =
if (limit == 1) {
stringResource(Res.string.settings_debrid_prepare_count_one)
} else {
stringResource(Res.string.settings_debrid_prepare_count_many, limit)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun DebridPrepareCountDialog(
selectedLimit: Int,
onLimitSelected: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val options = listOf(1, 2, 3, 5)
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.settings_debrid_prepare_stream_count),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { limit ->
val isSelected = limit == selectedLimit
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onLimitSelected(limit) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = prepareCountLabel(limit),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun DebridApiKeyRow(
isTablet: Boolean,
providerId: String,
title: String,
description: String,
value: String,
onApiKeyCommitted: (String) -> Unit,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 16.dp else 14.dp
val scope = rememberCoroutineScope()
var draft by rememberSaveable(value) { mutableStateOf(value) }
var isValidating by rememberSaveable(providerId) { mutableStateOf(false) }
var validationMessage by rememberSaveable(providerId, value) { mutableStateOf<String?>(null) }
val normalizedDraft = draft.trim()
val validMessage = stringResource(Res.string.settings_debrid_key_valid)
val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SettingsSecretTextField(
value = draft,
onValueChange = {
draft = it
validationMessage = null
},
modifier = Modifier.fillMaxWidth(),
label = "$title API key",
)
validationMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
draft = normalizedDraft
onApiKeyCommitted(normalizedDraft)
},
enabled = normalizedDraft != value && !isValidating,
) {
Text(stringResource(Res.string.action_save))
}
TextButton(
onClick = {
scope.launch {
isValidating = true
val valid = runCatching {
DebridCredentialValidator.validateProvider(providerId, normalizedDraft)
}.getOrDefault(false)
validationMessage = if (valid) validMessage else invalidMessage
isValidating = false
}
},
enabled = normalizedDraft.isNotBlank() && !isValidating,
) {
Text(stringResource(Res.string.action_validate))
}
}
}
}
@Composable
private fun DebridTemplateRow(
isTablet: Boolean,
title: String,
description: String,
value: String,
singleLine: Boolean,
onTemplateCommitted: (String) -> Unit,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 16.dp else 14.dp
var draft by rememberSaveable(value) { mutableStateOf(value) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.fillMaxWidth(),
singleLine = singleLine,
minLines = if (singleLine) 1 else 4,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f),
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surface,
),
)
Row(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { onTemplateCommitted(draft) },
enabled = draft != value,
) {
Text(stringResource(Res.string.action_save))
}
}
}
}
@Composable
private fun DebridInfoRow(
isTablet: Boolean,
text: String,
) {
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 14.dp else 12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

View file

@ -1,10 +1,14 @@
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 nuvio.composeapp.generated.resources.compose_settings_page_debrid
import nuvio.composeapp.generated.resources.Res
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.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_tmdb_description
import org.jetbrains.compose.resources.stringResource
@ -13,6 +17,7 @@ internal fun LazyListScope.integrationsContent(
isTablet: Boolean,
onTmdbClick: () -> Unit,
onMdbListClick: () -> Unit,
onDebridClick: () -> Unit,
) {
item {
SettingsSection(
@ -35,6 +40,14 @@ internal fun LazyListScope.integrationsContent(
isTablet = isTablet,
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,
)
}
}
}

View file

@ -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_appearance
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_homescreen
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
@ -129,6 +130,11 @@ internal enum class SettingsPage(
category = SettingsCategory.General,
parentPage = Integrations,
),
Debrid(
titleRes = Res.string.compose_settings_page_debrid,
category = SettingsCategory.General,
parentPage = Integrations,
),
TraktAuthentication(
titleRes = Res.string.compose_settings_page_trakt,
category = SettingsCategory.Account,

View file

@ -59,6 +59,8 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleUiState
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.HomeCatalogSettingsRepository
import com.nuvio.app.features.mdblist.MdbListSettings
@ -127,6 +129,10 @@ fun SettingsScreen(
MdbListSettingsRepository.ensureLoaded()
MdbListSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val debridSettings by remember {
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val traktAuthUiState by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
@ -232,6 +238,7 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@ -279,6 +286,7 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@ -336,6 +344,7 @@ private fun MobileSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@ -544,6 +553,7 @@ private fun MobileSettingsScreen(
isTablet = false,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = false,
@ -553,6 +563,10 @@ private fun MobileSettingsScreen(
isTablet = false,
settings = mdbListSettings,
)
SettingsPage.Debrid -> debridSettingsContent(
isTablet = false,
settings = debridSettings,
)
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = false,
uiState = traktAuthUiState,
@ -637,6 +651,7 @@ private fun TabletSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@ -904,6 +919,7 @@ private fun TabletSettingsScreen(
isTablet = true,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = true,
@ -913,6 +929,10 @@ private fun TabletSettingsScreen(
isTablet = true,
settings = mdbListSettings,
)
SettingsPage.Debrid -> debridSettingsContent(
isTablet = true,
settings = debridSettings,
)
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = true,
uiState = traktAuthUiState,

View file

@ -34,14 +34,14 @@ object StreamAutoPlaySelector {
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.directPlaybackUrl != null
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable()
}
if (bingeGroupMatch != null) return bingeGroupMatch
}
return when (mode) {
StreamAutoPlayMode.MANUAL -> null
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.directPlaybackUrl != null }
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable() }
StreamAutoPlayMode.REGEX_MATCH -> {
val pattern = regexPattern.trim()
@ -61,7 +61,8 @@ object StreamAutoPlaySelector {
} else null
val matchingStreams = candidateStreams.filter { stream ->
val url = stream.directPlaybackUrl ?: return@filter false
if (!stream.isAutoPlayable()) return@filter false
val url = stream.directPlaybackUrl.orEmpty()
val searchableText = buildString {
append(stream.addonName).append(' ')
@ -81,8 +82,11 @@ object StreamAutoPlaySelector {
}
if (matchingStreams.isEmpty()) return null
matchingStreams.firstOrNull { it.directPlaybackUrl != null }
matchingStreams.firstOrNull { it.isAutoPlayable() }
}
}
}
private fun StreamItem.isAutoPlayable(): Boolean =
directPlaybackUrl != null || isDirectDebridStream
}

View file

@ -6,15 +6,18 @@ import org.jetbrains.compose.resources.getString
data class StreamItem(
val name: String? = null,
val title: String? = null,
val description: String? = null,
val url: String? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val externalUrl: String? = null,
val sources: List<String> = emptyList(),
val sourceName: String? = null,
val addonName: String,
val addonId: String,
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
) {
val streamLabel: String
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
@ -25,13 +28,18 @@ data class StreamItem(
val directPlaybackUrl: String?
get() = url ?: externalUrl
val isDirectDebridStream: Boolean
get() = clientResolve?.isDirectDebridCandidate == true
val isTorrentStream: Boolean
get() = !infoHash.isNullOrBlank() ||
get() = !isDirectDebridStream && (
!infoHash.isNullOrBlank() ||
url.isMagnetLink() ||
externalUrl.isMagnetLink()
)
val hasPlayableSource: Boolean
get() = url != null || infoHash != null || externalUrl != null
get() = url != null || infoHash != null || externalUrl != null || clientResolve != null
}
private fun String?.isMagnetLink(): Boolean =
@ -40,6 +48,7 @@ private fun String?.isMagnetLink(): Boolean =
data class StreamBehaviorHints(
val bingeGroup: String? = null,
val notWebReady: Boolean = false,
val videoHash: String? = null,
val videoSize: Long? = null,
val filename: String? = null,
val proxyHeaders: StreamProxyHeaders? = null,
@ -50,6 +59,71 @@ data class StreamProxyHeaders(
val response: Map<String, String>? = 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(
val addonName: String,
val addonId: String,

View file

@ -26,9 +26,10 @@ object StreamParser {
val url = obj.string("url")
val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl")
val clientResolve = obj.objectValue("clientResolve")?.toClientResolve()
// Must have at least one playable source
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val proxyHeaders = hintsObj
@ -36,16 +37,20 @@ object StreamParser {
?.toProxyHeaders()
StreamItem(
name = obj.string("name"),
title = obj.string("title"),
description = obj.string("description") ?: obj.string("title"),
url = url,
infoHash = infoHash,
fileIdx = obj.int("fileIdx"),
externalUrl = externalUrl,
sources = obj.stringList("sources"),
addonName = addonName,
addonId = addonId,
clientResolve = clientResolve,
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
videoHash = hintsObj?.string("videoHash"),
videoSize = hintsObj?.long("videoSize"),
filename = hintsObj?.string("filename"),
proxyHeaders = proxyHeaders,
@ -58,10 +63,14 @@ object StreamParser {
this[name]?.jsonPrimitive?.contentOrNull
private fun JsonObject.int(name: String): Int? =
this[name]?.jsonPrimitive?.intOrNull
this[name]?.jsonPrimitive?.let { primitive ->
primitive.intOrNull ?: primitive.contentOrNull?.toIntOrNull()
}
private fun JsonObject.long(name: String): Long? =
this[name]?.jsonPrimitive?.longOrNull
this[name]?.jsonPrimitive?.let { primitive ->
primitive.longOrNull ?: primitive.contentOrNull?.toLongOrNull()
}
private fun JsonObject.boolean(name: String): Boolean? =
this[name]?.jsonPrimitive?.booleanOrNull
@ -69,6 +78,16 @@ object StreamParser {
private fun JsonObject.objectValue(name: String): JsonObject? =
this[name] as? JsonObject
private fun JsonObject.stringList(name: String): List<String> =
(this[name] as? JsonArray)
?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
.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> =
entries.mapNotNull { (key, value) ->
(value as? JsonPrimitive)?.contentOrNull
@ -87,4 +106,68 @@ object StreamParser {
response = responseHeaders,
)
}
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"),
)
}

View file

@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
@ -14,6 +16,7 @@ import com.nuvio.app.features.plugins.PluginRepositoryItem
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
@ -131,6 +134,7 @@ object StreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.getEnabledScrapersForType(type)
} else {
@ -141,7 +145,7 @@ object StreamsRepository {
groupByRepository = pluginUiState.groupStreamsByRepository,
)
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
@ -170,7 +174,7 @@ object StreamsRepository {
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
@ -194,6 +198,13 @@ object StreamsRepository {
streams = emptyList(),
isLoading = true,
)
} + debridTargets.map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}
_uiState.value = StreamsUiState(
requestToken = requestToken,
@ -211,13 +222,21 @@ object StreamsRepository {
.associate { it.addonId to it.scrapers.size }
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
val totalTasks = streamAddons.size + pluginRemainingByAddonId.values.sum()
val totalTasks = streamAddons.size +
pluginProviderGroups.sumOf { it.scrapers.size } +
debridTargets.size
val installedAddonNames = installedAddons
.map { it.displayTitle }
.toSet()
var autoSelectTriggered = false
var timeoutElapsed = false
var debridPreparationLaunched = false
fun publishCompletion(completion: StreamLoadCompletion) {
if (completions.trySend(completion).isFailure) {
log.d { "Ignoring late stream load completion after channel close" }
}
}
val timeoutJob = if (isAutoPlayEnabled) {
val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L
@ -271,7 +290,7 @@ object StreamsRepository {
log.d { "Fetching streams from: $url" }
val displayName = addon.addonName
val group = runCatching {
val group = runCatchingUnlessCancelled {
val payload = httpGetText(url)
StreamParser.parse(
payload = payload,
@ -299,7 +318,7 @@ object StreamsRepository {
)
},
)
completions.send(StreamLoadCompletion.Addon(group))
publishCompletion(StreamLoadCompletion.Addon(group))
}
}
@ -340,11 +359,25 @@ object StreamsRepository {
)
},
)
completions.send(completion)
publishCompletion(completion)
}
}
}
debridTargets.forEach { target ->
launch {
publishCompletion(
StreamLoadCompletion.Debrid(
DirectDebridStreamSource.fetchProviderStreams(
type = type,
videoId = videoId,
target = target,
),
),
)
}
}
repeat(totalTasks) {
when (val completion = completions.receive()) {
is StreamLoadCompletion.Addon -> {
@ -400,11 +433,46 @@ object StreamsRepository {
)
}
}
is StreamLoadCompletion.Debrid -> {
val result = completion.group
_uiState.update { current ->
val updated = current.groups.map { group ->
if (group.addonId == result.addonId) result else group
}
val anyLoading = updated.any { it.isLoading }
current.copy(
groups = updated,
isAnyLoading = anyLoading,
emptyStateReason = updated.toEmptyStateReason(anyLoading),
)
}
if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) {
debridPreparationLaunched = true
launch {
DirectDebridStreamPreparer.prepare(
streams = _uiState.value.groups.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,
),
)
}
}
}
}
}
}
}
completions.close()
if (isAutoPlayEnabled && !autoSelectTriggered) {
autoSelectTriggered = true
val allStreams = _uiState.value.groups.flatMap { it.streams }
@ -493,6 +561,7 @@ private data class PluginProviderGroup(
private sealed interface StreamLoadCompletion {
data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion
data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion
data class PluginScraper(
val addonId: String,
val streams: List<StreamItem>,
@ -538,6 +607,15 @@ private fun List<AddonStreamGroup>.toEmptyStateReason(anyLoading: Boolean): Stre
}
}
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)
}
private fun PluginRuntimeResult.toStreamItem(
scraper: PluginScraper,
addonName: String = scraper.name,

View file

@ -864,7 +864,7 @@ private fun LazyListScope.streamSection(
StreamCard(
stream = stream,
onClick = {
if (stream.directPlaybackUrl != null || stream.isTorrentStream) {
if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) {
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
}
},
@ -896,7 +896,7 @@ internal fun streamCardRenderKey(
append(':')
append(itemIndex)
append(':')
append(stream.url ?: stream.infoHash ?: stream.streamLabel)
append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
}
// ---------------------------------------------------------------------------
@ -970,7 +970,7 @@ private fun StreamCard(
onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream
val cardShape = RoundedCornerShape(12.dp)
Row(
modifier = modifier

View file

@ -0,0 +1,148 @@
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)
}
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,
)
}

View file

@ -0,0 +1,122 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamParser
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class DebridStreamFormatterTest {
private val formatter = DebridStreamFormatter()
@Test
fun `formats real client stream episode fields and behavior size`() {
val stream = StreamParser.parse(
payload = clientStreamPayload(),
addonName = "Torbox Instant",
addonId = "debrid:torbox",
).single()
val formatted = formatter.format(
stream = stream,
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
streamDescriptionTemplate = CLIENT_TEMPLATE,
),
)
val description = formatted.description.orEmpty()
assertEquals(0, stream.clientResolve?.fileIdx)
assertContains(description, "S05")
assertContains(description, "E02")
assertContains(description, "6.3 GB")
assertFalse(description.contains("6761331156"))
}
@Test
fun `formats season episode from parsed fields when top level resolve omits them`() {
val stream = StreamParser.parse(
payload = clientStreamPayload(includeTopLevelSeasonEpisode = false),
addonName = "Torbox Instant",
addonId = "debrid:torbox",
).single()
val formatted = formatter.format(
stream = stream,
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
streamDescriptionTemplate = CLIENT_TEMPLATE,
),
)
val description = formatted.description.orEmpty()
assertContains(description, "S05")
assertContains(description, "E02")
assertContains(description, "6.3 GB")
}
private fun clientStreamPayload(includeTopLevelSeasonEpisode: Boolean = true): String {
val seasonEpisode = if (includeTopLevelSeasonEpisode) {
"""
"season": 5,
"episode": 2,
""".trimIndent()
} else {
""
}
return """
{
"streams": [
{
"name": "TB 2160p cached",
"description": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"clientResolve": {
"type": "debrid",
"service": "torbox",
"isCached": true,
"infoHash": "cb7286fb422ed0643037523e7b09446734e9dbc4",
"sources": [],
"fileIdx": "0",
"filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"title": "The Boys",
"torrentName": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
$seasonEpisode
"stream": {
"raw": {
"parsed": {
"resolution": "2160p",
"quality": "WEB-DL",
"codec": "hevc",
"audio": ["Atmos", "Dolby Digital Plus"],
"channels": ["5.1"],
"hdr": ["DV", "HDR10+"],
"group": "Kitsune",
"seasons": [5],
"episodes": [2],
"raw_title": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv"
}
}
}
},
"behaviorHints": {
"filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"videoSize": 6761331156
}
}
]
}
""".trimIndent()
}
private companion object {
private const val CLIENT_TEMPLATE =
"{stream.title::exists[\"🍿 {stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year}) \"||\"\"]}\n" +
"{stream.season::>=0[\"🍂 S\"||\"\"]}{stream.season::<=9[\"0\"||\"\"]}{stream.season::>0[\"{stream.season} \"||\"\"]}{stream.episode::>=0[\"🎞️ E\"||\"\"]}{stream.episode::<=9[\"0\"||\"\"]}{stream.episode::>0[\"{stream.episode} \"||\"\"]}\n" +
"{stream.quality::exists[\"🎥 {stream.quality} \"||\"\"]}{stream.visualTags::exists[\"📺 {stream.visualTags::join(' | ')} \"||\"\"]}\n" +
"{stream.audioTags::exists[\"🎧 {stream.audioTags::join(' | ')} \"||\"\"]}{stream.audioChannels::exists[\"🔊 {stream.audioChannels::join(' | ')}\"||\"\"]}\n" +
"{stream.size::>0[\"📦 {stream.size::bytes} \"||\"\"]}{stream.encode::exists[\"🎞️ {stream.encode} \"||\"\"]}{stream.indexer::exists[\"📡{stream.indexer}\"||\"\"]}\n" +
"{service.cached::istrue[\"⚡Ready \"||\"\"]}{service.cached::isfalse[\"❌ Not Ready \"||\"\"]}{service.shortName::exists[\"({service.shortName}) \"||\"\"]}{stream.type::=Debrid[\"☁️ Debrid \"||\"\"]}🔍{addon.name}"
}
}

View file

@ -0,0 +1,45 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertEquals
class DebridStreamTemplateEngineTest {
private val engine = DebridStreamTemplateEngine()
@Test
fun `renders nested condition branches and transforms`() {
val rendered = engine.render(
"{stream.resolution::=2160p[\"4K {service.shortName} \"||\"\"]}{stream.title::title}",
mapOf(
"stream.resolution" to "2160p",
"stream.title" to "sample movie",
"service.shortName" to "RD",
),
)
assertEquals("4K RD Sample Movie", rendered)
}
@Test
fun `formats bytes and joins list values`() {
val rendered = engine.render(
"{stream.size::bytes} {stream.audioTags::join(' | ')}",
mapOf(
"stream.size" to 1_610_612_736L,
"stream.audioTags" to listOf("DTS", "Atmos"),
),
)
assertEquals("1.5 GB DTS | Atmos", rendered)
}
@Test
fun `renders Debrid size values as readable text while keeping numeric comparisons`() {
val rendered = engine.render(
"{stream.size::>0[\"{stream.size}\"||\"\"]}",
mapOf("stream.size" to DebridTemplateBytes(7_361_184_308L)),
)
assertEquals("6.9 GB", rendered)
}
}

View file

@ -0,0 +1,27 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertEquals
class DirectDebridConfigEncoderTest {
@Test
fun `encodes Torbox config exactly like TV`() {
val encoded = DirectDebridConfigEncoder().encodeTorbox("tb_key")
assertEquals(
"eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InRvcmJveCIsImFwaUtleSI6InRiX2tleSJ9XSwiZW5hYmxlVG9ycmVudCI6ZmFsc2V9",
encoded,
)
}
@Test
fun `escapes API key before base64 encoding`() {
val encoded = DirectDebridConfigEncoder().encode(
DebridServiceCredential(DebridProviders.RealDebrid, "rd\"key\\line"),
)
val expected = "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InJlYWxkZWJyaWQiLCJhcGlLZXkiOiJyZFwia2V5XFxsaW5lIn1dLCJlbmFibGVUb3JyZW50IjpmYWxzZX0="
assertEquals(expected, encoded)
}
}

View file

@ -0,0 +1,72 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamItem
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DirectDebridStreamFilterTest {
@Test
fun `keeps only cached supported debrid streams`() {
val torbox = stream(service = DebridProviders.TORBOX_ID, cached = true)
val uncached = stream(service = DebridProviders.TORBOX_ID, cached = false)
val unsupported = stream(service = "other", cached = true)
val torrent = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, type = "torrent")
val filtered = DirectDebridStreamFilter.filterInstant(listOf(torbox, uncached, unsupported, torrent))
assertEquals(1, filtered.size)
assertEquals("Torbox Instant", filtered.single().addonName)
assertEquals("debrid:torbox", filtered.single().addonId)
}
@Test
fun `dedupes by hash file and filename identity`() {
val first = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "ABC", fileIdx = 2)
val duplicate = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 2)
val otherFile = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 3)
val filtered = DirectDebridStreamFilter.filterInstant(listOf(first, duplicate, otherFile))
assertEquals(2, filtered.size)
}
@Test
fun `direct debrid stream is not treated as unsupported torrent`() {
val direct = stream(service = DebridProviders.TORBOX_ID, cached = true, infoHash = "hash")
val plainTorrent = StreamItem(
name = "Torrent",
infoHash = "hash",
addonName = "Addon",
addonId = "addon",
)
assertTrue(direct.isDirectDebridStream)
assertFalse(direct.isTorrentStream)
assertTrue(plainTorrent.isTorrentStream)
}
private fun stream(
service: String?,
cached: Boolean?,
type: String = "debrid",
infoHash: String = "hash",
fileIdx: Int = 1,
): StreamItem =
StreamItem(
name = "Stream",
addonName = "Direct Debrid",
addonId = "debrid",
clientResolve = StreamClientResolve(
type = type,
service = service,
isCached = cached,
infoHash = infoHash,
fileIdx = fileIdx,
filename = "video.mkv",
),
)
}

View file

@ -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 = "Torbox Instant",
addonId = "debrid:torbox",
clientResolve = StreamClientResolve(
type = "debrid",
service = DebridProviders.TORBOX_ID,
isCached = true,
infoHash = infoHash,
fileIdx = 1,
filename = "video.mkv",
),
)
}

View file

@ -145,16 +145,49 @@ class StreamAutoPlaySelectorTest {
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)
}
private fun stream(
addonName: String,
url: String? = null,
name: String? = null,
bingeGroup: String? = null,
directDebrid: Boolean = false,
): StreamItem = StreamItem(
name = name,
url = url,
addonName = addonName,
addonId = addonName,
clientResolve = if (directDebrid) {
StreamClientResolve(
type = "debrid",
service = "torbox",
isCached = true,
infoHash = "hash",
)
} else {
null
},
behaviorHints = StreamBehaviorHints(
bingeGroup = bingeGroup,
),

View file

@ -119,4 +119,56 @@ class StreamParserTest {
assertEquals("video/mp4", responseHeaders["content-type"])
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 = "Direct Debrid",
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)
}
}

View file

@ -0,0 +1,123 @@
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 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 streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey)
actual fun saveTorboxApiKey(apiKey: String) {
saveString(torboxApiKeyKey, apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey)
actual fun saveRealDebridApiKey(apiKey: String) {
saveString(realDebridApiKeyKey, apiKey)
}
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
saveInt(instantPlaybackPreparationLimitKey, limit)
}
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)) }
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(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.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
}

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=59
MARKETING_VERSION=0.1.19
CURRENT_PROJECT_VERSION=62
MARKETING_VERSION=0.1.20