mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
Merge branch 'cmp-rewrite' into indonesian-locale
This commit is contained in:
commit
cbdf350663
55 changed files with 4281 additions and 127 deletions
58
.github/PULL_REQUEST_TEMPLATE.md
vendored
58
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -4,44 +4,64 @@
|
||||||
|
|
||||||
## PR type
|
## PR type
|
||||||
|
|
||||||
<!-- Pick one and delete the others -->
|
<!-- Check exactly one. PRs outside these types are not accepted. -->
|
||||||
- Bug fix
|
- [ ] Reproducible bug fix
|
||||||
- Small maintenance improvement
|
- [ ] UI glitch/bug fix
|
||||||
- Docs fix
|
- [ ] Behavior bug/regression fix
|
||||||
- Translation update
|
- [ ] Small maintenance only, with no UI or behavior change
|
||||||
- Approved larger change (link approval below)
|
- [ ] Docs accuracy fix
|
||||||
|
- [ ] Translation/localization only
|
||||||
|
- [ ] Approved larger or directional change
|
||||||
|
|
||||||
## Why
|
## 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
|
## Policy check
|
||||||
|
|
||||||
<!-- ALL boxes must be checked or the PR will be closed without review. -->
|
<!-- 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.
|
- [ ] I have read and understood `CONTRIBUTING.md`.
|
||||||
- [ ] This PR does not add a new major feature without prior approval.
|
- [ ] This PR is small, focused, and limited to one problem.
|
||||||
- [ ] This PR is small in scope and focused on one problem.
|
- [ ] This PR is not cosmetic-only.
|
||||||
- [ ] If this is a larger or directional change, I linked the **approved** feature request issue below.
|
- [ ] 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. -->
|
<!-- List anything intentionally not changed. If this is a bug fix, confirm it does not include extra UI polish or behavior tweaks. -->
|
||||||
<!-- Example: Approved in #123 -->
|
|
||||||
|
|
||||||
## Testing
|
## 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)
|
## 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
|
## Breaking changes
|
||||||
|
|
||||||
<!-- Any breaking behavior/config/schema changes? If none, write: None -->
|
<!-- Any breaking behavior/config/schema changes? If none, write: None. -->
|
||||||
|
|
||||||
## Linked issues
|
## 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. -->
|
||||||
|
|
|
||||||
102
.github/workflows/pr-template-check.yml
vendored
102
.github/workflows/pr-template-check.yml
vendored
|
|
@ -29,9 +29,44 @@ jobs:
|
||||||
return (next === -1 ? rest : rest.slice(0, next)).trim();
|
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 missing = [];
|
||||||
const empty = [];
|
const empty = [];
|
||||||
|
const failedRules = [];
|
||||||
|
|
||||||
for (const name of required) {
|
for (const name of required) {
|
||||||
const content = sectionContent(name);
|
const content = sectionContent(name);
|
||||||
|
|
@ -40,34 +75,73 @@ jobs:
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleaned = content
|
const cleaned = cleanedContent(name);
|
||||||
.replace(/<!--[\s\S]*?-->/g, "")
|
const normalized = cleaned.toLowerCase();
|
||||||
.replace(/`/g, "")
|
const allowsNone = name === "Breaking changes" || name === "Screenshots / Video";
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cleaned.length < 4 ||
|
cleaned.length < 4 ||
|
||||||
cleaned === "none" ||
|
(!allowsNone && ["none", "n/a", "na", "not applicable"].includes(normalized)) ||
|
||||||
cleaned.includes("what changed in this pr") ||
|
normalized.includes("what changed in this pr") ||
|
||||||
cleaned.includes("why this change is needed") ||
|
normalized.includes("why this change is needed") ||
|
||||||
cleaned.includes("what you tested") ||
|
normalized.includes("what you tested") ||
|
||||||
cleaned.includes("example: fixes #123")
|
normalized.includes("example: fixes #123")
|
||||||
) {
|
) {
|
||||||
empty.push(name);
|
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 = [
|
const lines = [
|
||||||
"PR description is missing required detail.",
|
"PR description is missing required detail.",
|
||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
if (missing.length) lines.push(`Missing sections: ${missing.join(", ")}`);
|
if (missing.length) lines.push(`Missing sections: ${missing.join(", ")}`);
|
||||||
if (empty.length) lines.push(`Incomplete sections: ${empty.join(", ")}`);
|
if (empty.length) lines.push(`Incomplete sections: ${empty.join(", ")}`);
|
||||||
|
if (failedRules.length) lines.push(`Failed policy rules: ${failedRules.join(" ")}`);
|
||||||
lines.push("");
|
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"));
|
core.setFailed(lines.join("\n"));
|
||||||
} else {
|
} else {
|
||||||
core.info("PR template check passed.");
|
core.info("PR template check passed.");
|
||||||
|
|
|
||||||
103
CONTRIBUTING.md
103
CONTRIBUTING.md
|
|
@ -2,33 +2,84 @@
|
||||||
|
|
||||||
Thanks for helping improve Nuvio.
|
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.
|
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
|
- Reproducible bug fixes for documented issues
|
||||||
- Small stability improvements
|
- UI glitch fixes for visible bugs or regressions, with before/after proof
|
||||||
- Minor maintenance work
|
- 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
|
- 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
|
- New major features
|
||||||
- Product direction changes
|
- Product direction changes
|
||||||
- Large UX / UI redesigns
|
- UX/UI redesigns
|
||||||
- Cosmetic-only changes
|
- Cosmetic-only UI changes
|
||||||
- Refactors without a clear user-facing or maintenance benefit
|
- "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.
|
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.**
|
**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.
|
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:
|
Please make sure your PR is all of the following:
|
||||||
|
|
||||||
- Small in scope
|
- Allowed by this policy
|
||||||
- Focused on one problem
|
- Small in scope and focused on one problem
|
||||||
- Clearly aligned with the current direction of the project
|
- Clearly aligned with the current direction of the project
|
||||||
- Not cosmetic-only, unless it is a translation PR
|
- Not cosmetic-only
|
||||||
- Not a new major feature unless it was discussed and approved first
|
- Not changing behavior unless it fixes a linked bug or has explicit approval
|
||||||
- **If large or non-trivial: linked to an approved feature request issue**
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
outDir.resolve("com/nuvio/app/core/build").apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
resolve("AppVersionConfig.kt").writeText(
|
resolve("AppVersionConfig.kt").writeText(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
||||||
import com.nuvio.app.features.addons.AddonStorage
|
import com.nuvio.app.features.addons.AddonStorage
|
||||||
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||||
import com.nuvio.app.features.collection.CollectionStorage
|
import com.nuvio.app.features.collection.CollectionStorage
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsStorage
|
||||||
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
||||||
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
||||||
import com.nuvio.app.features.downloads.DownloadsStorage
|
import com.nuvio.app.features.downloads.DownloadsStorage
|
||||||
|
|
@ -73,6 +74,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
SearchHistoryStorage.initialize(applicationContext)
|
SearchHistoryStorage.initialize(applicationContext)
|
||||||
SeasonViewModeStorage.initialize(applicationContext)
|
SeasonViewModeStorage.initialize(applicationContext)
|
||||||
PosterCardStyleStorage.initialize(applicationContext)
|
PosterCardStyleStorage.initialize(applicationContext)
|
||||||
|
DebridSettingsStorage.initialize(applicationContext)
|
||||||
TmdbSettingsStorage.initialize(applicationContext)
|
TmdbSettingsStorage.initialize(applicationContext)
|
||||||
MdbListSettingsStorage.initialize(applicationContext)
|
MdbListSettingsStorage.initialize(applicationContext)
|
||||||
TraktAuthStorage.initialize(applicationContext)
|
TraktAuthStorage.initialize(applicationContext)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1068,7 +1068,7 @@
|
||||||
<string name="streams_resume_from_percent">Pokračovat od %1$d%%</string>
|
<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_resume_from_time">Pokračovat od %1$s</string>
|
||||||
<string name="streams_size">VELIKOST %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_close">Zavřít trailer</string>
|
||||||
<string name="trailer_unable_to_play">Trailer nelze přehrát</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>
|
<string name="trakt_lists_load_failed">Nepodařilo se načíst seznamy Trakt</string>
|
||||||
|
|
|
||||||
|
|
@ -1111,6 +1111,7 @@
|
||||||
<string name="external_player_failed">Tidak dapat membuka player eksternal</string>
|
<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_not_configured">Pilih player eksternal terlebih dahulu di pengaturan</string>
|
||||||
<string name="external_player_unavailable">Tidak ada player eksternal yang tersedia</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_close">Tutup trailer</string>
|
||||||
<string name="trailer_unable_to_play">Tidak dapat memutar trailer</string>
|
<string name="trailer_unable_to_play">Tidak dapat memutar trailer</string>
|
||||||
<string name="trakt_lists_load_failed">Gagal memuat daftar Trakt</string>
|
<string name="trakt_lists_load_failed">Gagal memuat daftar Trakt</string>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<string name="action_resume">Resume</string>
|
<string name="action_resume">Resume</string>
|
||||||
<string name="action_retry">Retry</string>
|
<string name="action_retry">Retry</string>
|
||||||
<string name="action_save">Save</string>
|
<string name="action_save">Save</string>
|
||||||
|
<string name="action_validate">Validate</string>
|
||||||
<string name="addon_installing">Installing</string>
|
<string name="addon_installing">Installing</string>
|
||||||
<string name="addon_title">Addons</string>
|
<string name="addon_title">Addons</string>
|
||||||
<string name="addons_badge_active">Active</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_popular">Popular</string>
|
||||||
<string name="collections_editor_tmdb_sort_top_rated">Top Rated</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_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_list">TMDB List</string>
|
||||||
<string name="collections_editor_tmdb_subtitle_movie_collection">TMDB Movie Collection</string>
|
<string name="collections_editor_tmdb_subtitle_movie_collection">TMDB Movie Collection</string>
|
||||||
<string name="collections_editor_tmdb_subtitle_production">Production</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_appearance">Layout</string>
|
||||||
<string name="compose_settings_page_content_discovery">Content & Discovery</string>
|
<string name="compose_settings_page_content_discovery">Content & Discovery</string>
|
||||||
<string name="compose_settings_page_continue_watching">Continue Watching</string>
|
<string name="compose_settings_page_continue_watching">Continue Watching</string>
|
||||||
|
<string name="compose_settings_page_debrid">Debrid</string>
|
||||||
<string name="compose_settings_page_homescreen">Home Layout</string>
|
<string name="compose_settings_page_homescreen">Home Layout</string>
|
||||||
<string name="compose_settings_page_integrations">Integrations</string>
|
<string name="compose_settings_page_integrations">Integrations</string>
|
||||||
<string name="compose_settings_page_licenses_attributions">Licenses & Attribution</string>
|
<string name="compose_settings_page_licenses_attributions">Licenses & Attribution</string>
|
||||||
|
|
@ -573,6 +588,26 @@
|
||||||
<string name="settings_integrations_section_title">Integrations</string>
|
<string name="settings_integrations_section_title">Integrations</string>
|
||||||
<string name="settings_integrations_tmdb_description">Metadata enrichment controls</string>
|
<string name="settings_integrations_tmdb_description">Metadata enrichment controls</string>
|
||||||
<string name="settings_integrations_mdblist_description">External ratings providers</string>
|
<string name="settings_integrations_mdblist_description">External ratings providers</string>
|
||||||
|
<string name="settings_integrations_debrid_description">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_add_api_key_first">Add your MDBList API key below before turning ratings on.</string>
|
||||||
<string name="settings_mdb_api_key_description">Required to fetch ratings from MDBList</string>
|
<string name="settings_mdb_api_key_description">Required to fetch ratings from MDBList</string>
|
||||||
<string name="settings_mdb_api_key_label">API Key</string>
|
<string name="settings_mdb_api_key_label">API Key</string>
|
||||||
|
|
@ -1107,7 +1142,10 @@
|
||||||
<string name="streams_resume_from_percent">Resume from %1$d%</string>
|
<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_resume_from_time">Resume from %1$s</string>
|
||||||
<string name="streams_size">SIZE %1$s</string>
|
<string name="streams_size">SIZE %1$s</string>
|
||||||
<string name="streams_torrent_not_supported">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't open external player</string>
|
<string name="external_player_failed">Couldn't open external player</string>
|
||||||
<string name="external_player_not_configured">Choose an external player in settings first</string>
|
<string name="external_player_not_configured">Choose an external player in settings first</string>
|
||||||
<string name="external_player_unavailable">No external player is available</string>
|
<string name="external_player_unavailable">No external player is available</string>
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,9 @@ import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.catalog.CatalogRepository
|
import com.nuvio.app.features.catalog.CatalogRepository
|
||||||
import com.nuvio.app.features.catalog.CatalogScreen
|
import com.nuvio.app.features.catalog.CatalogScreen
|
||||||
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
|
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
|
||||||
|
import com.nuvio.app.features.debrid.toastMessage
|
||||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||||
import com.nuvio.app.features.downloads.DownloadsScreen
|
import com.nuvio.app.features.downloads.DownloadsScreen
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
|
|
@ -159,6 +162,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
|
||||||
import com.nuvio.app.features.collection.FolderDetailScreen
|
import com.nuvio.app.features.collection.FolderDetailScreen
|
||||||
import com.nuvio.app.features.collection.FolderDetailRepository
|
import com.nuvio.app.features.collection.FolderDetailRepository
|
||||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
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.StreamLaunch
|
||||||
import com.nuvio.app.features.streams.StreamLaunchStore
|
import com.nuvio.app.features.streams.StreamLaunchStore
|
||||||
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
||||||
|
|
@ -467,6 +471,11 @@ fun App() {
|
||||||
AuthScreen(modifier = Modifier.fillMaxSize())
|
AuthScreen(modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
AppGateScreen.ProfileSelection.name -> {
|
AppGateScreen.ProfileSelection.name -> {
|
||||||
|
PlatformBackHandler(enabled = gateScreen == AppGateScreen.ProfileSelection.name) {
|
||||||
|
if (!autoSkipProfileSelection) {
|
||||||
|
gateScreen = AppGateScreen.Main.name
|
||||||
|
}
|
||||||
|
}
|
||||||
ProfileSelectionScreen(
|
ProfileSelectionScreen(
|
||||||
onProfileSelected = { profile ->
|
onProfileSelected = { profile ->
|
||||||
ProfileRepository.selectProfile(profile.profileIndex)
|
ProfileRepository.selectProfile(profile.profileIndex)
|
||||||
|
|
@ -489,6 +498,9 @@ fun App() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AppGateScreen.ProfileEdit.name -> {
|
AppGateScreen.ProfileEdit.name -> {
|
||||||
|
PlatformBackHandler(enabled = gateScreen == AppGateScreen.ProfileEdit.name) {
|
||||||
|
gateScreen = AppGateScreen.ProfileSelection.name
|
||||||
|
}
|
||||||
ProfileEditScreen(
|
ProfileEditScreen(
|
||||||
profile = editingProfile,
|
profile = editingProfile,
|
||||||
onBack = { gateScreen = AppGateScreen.ProfileSelection.name },
|
onBack = { gateScreen = AppGateScreen.ProfileSelection.name },
|
||||||
|
|
@ -1316,6 +1328,8 @@ private fun MainAppContent(
|
||||||
return@composable
|
return@composable
|
||||||
}
|
}
|
||||||
val pauseDescription = launch.pauseDescription
|
val pauseDescription = launch.pauseDescription
|
||||||
|
val streamRouteScope = rememberCoroutineScope()
|
||||||
|
var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) }
|
||||||
val lifecycleOwner = backStackEntry
|
val lifecycleOwner = backStackEntry
|
||||||
DisposableEffect(lifecycleOwner, route.launchId) {
|
DisposableEffect(lifecycleOwner, route.launchId) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
|
@ -1465,7 +1479,30 @@ private fun MainAppContent(
|
||||||
if (reuseNavigated) return@LaunchedEffect
|
if (reuseNavigated) return@LaunchedEffect
|
||||||
if (autoPlayHandled) return@LaunchedEffect
|
if (autoPlayHandled) return@LaunchedEffect
|
||||||
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
|
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
|
||||||
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
||||||
|
val 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
|
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
|
||||||
autoPlayHandled = true
|
autoPlayHandled = true
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||||
|
|
@ -1537,12 +1574,46 @@ private fun MainAppContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openSelectedStream(
|
fun openSelectedStream(
|
||||||
stream: com.nuvio.app.features.streams.StreamItem,
|
stream: StreamItem,
|
||||||
resolvedResumePositionMs: Long?,
|
resolvedResumePositionMs: Long?,
|
||||||
resolvedResumeProgressFraction: Float?,
|
resolvedResumeProgressFraction: Float?,
|
||||||
forceExternal: Boolean,
|
forceExternal: Boolean,
|
||||||
forceInternal: 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
|
val sourceUrl = stream.directPlaybackUrl ?: return
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
|
|
@ -1604,47 +1675,69 @@ private fun MainAppContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
StreamsScreen(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
type = launch.type,
|
StreamsScreen(
|
||||||
videoId = effectiveVideoId,
|
type = launch.type,
|
||||||
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
|
videoId = effectiveVideoId,
|
||||||
parentMetaType = launch.parentMetaType ?: launch.type,
|
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
|
||||||
title = launch.title,
|
parentMetaType = launch.parentMetaType ?: launch.type,
|
||||||
logo = launch.logo,
|
title = launch.title,
|
||||||
poster = launch.poster,
|
logo = launch.logo,
|
||||||
background = launch.background,
|
poster = launch.poster,
|
||||||
seasonNumber = launch.seasonNumber,
|
background = launch.background,
|
||||||
episodeNumber = launch.episodeNumber,
|
seasonNumber = launch.seasonNumber,
|
||||||
episodeTitle = launch.episodeTitle,
|
episodeNumber = launch.episodeNumber,
|
||||||
episodeThumbnail = launch.episodeThumbnail,
|
episodeTitle = launch.episodeTitle,
|
||||||
resumePositionMs = launch.resumePositionMs,
|
episodeThumbnail = launch.episodeThumbnail,
|
||||||
resumeProgressFraction = launch.resumeProgressFraction,
|
resumePositionMs = launch.resumePositionMs,
|
||||||
manualSelection = launch.manualSelection,
|
resumeProgressFraction = launch.resumeProgressFraction,
|
||||||
startFromBeginning = launch.startFromBeginning,
|
manualSelection = launch.manualSelection,
|
||||||
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
startFromBeginning = launch.startFromBeginning,
|
||||||
openSelectedStream(
|
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
||||||
stream = stream,
|
openSelectedStream(
|
||||||
resolvedResumePositionMs = resolvedResumePositionMs,
|
stream = stream,
|
||||||
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
|
resolvedResumePositionMs = resolvedResumePositionMs,
|
||||||
forceExternal = false,
|
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
|
||||||
forceInternal = false,
|
forceExternal = false,
|
||||||
)
|
forceInternal = false,
|
||||||
},
|
)
|
||||||
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
},
|
||||||
openSelectedStream(
|
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
||||||
stream = stream,
|
openSelectedStream(
|
||||||
resolvedResumePositionMs = resolvedResumePositionMs,
|
stream = stream,
|
||||||
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
|
resolvedResumePositionMs = resolvedResumePositionMs,
|
||||||
forceExternal = openExternally,
|
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
|
||||||
forceInternal = !openExternally,
|
forceExternal = openExternally,
|
||||||
)
|
forceInternal = !openExternally,
|
||||||
},
|
)
|
||||||
onBack = {
|
},
|
||||||
StreamsRepository.clear()
|
onBack = {
|
||||||
navController.popBackStack()
|
StreamsRepository.clear()
|
||||||
},
|
navController.popBackStack()
|
||||||
modifier = Modifier.fillMaxSize(),
|
},
|
||||||
)
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
if (resolvingDebridStream) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.82f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.streams_finding_source),
|
||||||
|
color = Color.White.copy(alpha = 0.82f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable<PlayerRoute>(
|
composable<PlayerRoute>(
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
import com.nuvio.app.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsStorage
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||||
|
|
@ -157,6 +159,7 @@ object ProfileSettingsSync {
|
||||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||||
PlayerSettingsRepository.uiState.map { "player" },
|
PlayerSettingsRepository.uiState.map { "player" },
|
||||||
|
DebridSettingsRepository.uiState.map { "debrid" },
|
||||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||||
|
|
@ -202,6 +205,7 @@ object ProfileSettingsSync {
|
||||||
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
|
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
|
||||||
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
|
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
|
||||||
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
|
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
|
||||||
|
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
|
||||||
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
||||||
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
||||||
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
|
|
@ -226,6 +230,9 @@ object ProfileSettingsSync {
|
||||||
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
||||||
PlayerSettingsRepository.onProfileChanged()
|
PlayerSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
|
DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings)
|
||||||
|
DebridSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings)
|
TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings)
|
||||||
TmdbSettingsRepository.onProfileChanged()
|
TmdbSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
|
|
@ -255,6 +262,7 @@ object ProfileSettingsSync {
|
||||||
ThemeSettingsRepository.ensureLoaded()
|
ThemeSettingsRepository.ensureLoaded()
|
||||||
PosterCardStyleRepository.ensureLoaded()
|
PosterCardStyleRepository.ensureLoaded()
|
||||||
PlayerSettingsRepository.ensureLoaded()
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
TmdbSettingsRepository.ensureLoaded()
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
MdbListSettingsRepository.ensureLoaded()
|
MdbListSettingsRepository.ensureLoaded()
|
||||||
MetaScreenSettingsRepository.ensureLoaded()
|
MetaScreenSettingsRepository.ensureLoaded()
|
||||||
|
|
@ -277,6 +285,7 @@ object ProfileSettingsSync {
|
||||||
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||||
"player=${PlayerSettingsRepository.uiState.value}",
|
"player=${PlayerSettingsRepository.uiState.value}",
|
||||||
|
"debrid=${DebridSettingsRepository.uiState.value}",
|
||||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||||
|
|
@ -299,6 +308,7 @@ private data class MobileProfileSettingsFeatures(
|
||||||
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
|
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
|
||||||
@SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
|
@SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
||||||
|
|
|
||||||
|
|
@ -1125,6 +1125,7 @@ private fun TmdbSourcePickerScreen(
|
||||||
val sorts = listOf(
|
val sorts = listOf(
|
||||||
TmdbCollectionSort.POPULAR_DESC,
|
TmdbCollectionSort.POPULAR_DESC,
|
||||||
TmdbCollectionSort.VOTE_AVERAGE_DESC,
|
TmdbCollectionSort.VOTE_AVERAGE_DESC,
|
||||||
|
TmdbCollectionSort.VOTE_COUNT_DESC,
|
||||||
if (state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth) {
|
if (state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth) {
|
||||||
TmdbCollectionSort.FIRST_AIR_DATE_DESC
|
TmdbCollectionSort.FIRST_AIR_DATE_DESC
|
||||||
} else {
|
} 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.ORIGINAL -> stringResource(Res.string.collections_editor_tmdb_sort_original)
|
||||||
TmdbCollectionSort.POPULAR_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_popular)
|
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_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.RELEASE_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
|
||||||
TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
|
TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ enum class TmdbCollectionSort(val value: String) {
|
||||||
ORIGINAL("original"),
|
ORIGINAL("original"),
|
||||||
POPULAR_DESC("popularity.desc"),
|
POPULAR_DESC("popularity.desc"),
|
||||||
VOTE_AVERAGE_DESC("vote_average.desc"),
|
VOTE_AVERAGE_DESC("vote_average.desc"),
|
||||||
|
VOTE_COUNT_DESC("vote_count.desc"),
|
||||||
RELEASE_DATE_DESC("primary_release_date.desc"),
|
RELEASE_DATE_DESC("primary_release_date.desc"),
|
||||||
FIRST_AIR_DATE_DESC("first_air_date.desc"),
|
FIRST_AIR_DATE_DESC("first_air_date.desc"),
|
||||||
}
|
}
|
||||||
|
|
@ -149,6 +150,8 @@ data class TmdbCollectionFilters(
|
||||||
val withCompanies: String? = null,
|
val withCompanies: String? = null,
|
||||||
val withNetworks: String? = null,
|
val withNetworks: String? = null,
|
||||||
val year: Int? = null,
|
val year: Int? = null,
|
||||||
|
val watchRegion: String? = null,
|
||||||
|
val withWatchProviders: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class TmdbSourceImportMetadata(
|
data class TmdbSourceImportMetadata(
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,11 @@ object TmdbCollectionSourceResolver {
|
||||||
putIfNotBlank("with_original_language", filters.withOriginalLanguage)
|
putIfNotBlank("with_original_language", filters.withOriginalLanguage)
|
||||||
putIfNotBlank("with_origin_country", filters.withOriginCountry)
|
putIfNotBlank("with_origin_country", filters.withOriginCountry)
|
||||||
putIfNotBlank("with_keywords", filters.withKeywords)
|
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("year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.MOVIE }?.toString())
|
||||||
putIfNotBlank("first_air_date_year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.TV }?.toString())
|
putIfNotBlank("first_air_date_year", filters.year?.takeIf { mediaType == TmdbCollectionMediaType.TV }?.toString())
|
||||||
putIfNotBlank(
|
putIfNotBlank(
|
||||||
|
|
@ -358,6 +363,7 @@ object TmdbCollectionSourceResolver {
|
||||||
compareByDescending<MetaPreview> { it.imdbRating?.toDoubleOrNull() ?: -1.0 }
|
compareByDescending<MetaPreview> { it.imdbRating?.toDoubleOrNull() ?: -1.0 }
|
||||||
.thenByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() },
|
.thenByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() },
|
||||||
)
|
)
|
||||||
|
TmdbCollectionSort.VOTE_COUNT_DESC.value -> sortedByDescending { it.voteCount ?: 0 }
|
||||||
TmdbCollectionSort.RELEASE_DATE_DESC.value,
|
TmdbCollectionSort.RELEASE_DATE_DESC.value,
|
||||||
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> sortedByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() }
|
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> sortedByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() }
|
||||||
TmdbCollectionSort.POPULAR_DESC.value,
|
TmdbCollectionSort.POPULAR_DESC.value,
|
||||||
|
|
@ -395,6 +401,7 @@ object TmdbCollectionSourceResolver {
|
||||||
TmdbCollectionMediaType.TV -> firstAirDate
|
TmdbCollectionMediaType.TV -> firstAirDate
|
||||||
},
|
},
|
||||||
popularity = popularity,
|
popularity = popularity,
|
||||||
|
voteCount = voteCount,
|
||||||
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
|
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -412,6 +419,7 @@ object TmdbCollectionSourceResolver {
|
||||||
releaseInfo = releaseDate?.take(4),
|
releaseInfo = releaseDate?.take(4),
|
||||||
rawReleaseDate = releaseDate,
|
rawReleaseDate = releaseDate,
|
||||||
popularity = popularity,
|
popularity = popularity,
|
||||||
|
voteCount = voteCount,
|
||||||
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
|
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -440,6 +448,7 @@ object TmdbCollectionSourceResolver {
|
||||||
TmdbCollectionMediaType.TV -> firstAirDate
|
TmdbCollectionMediaType.TV -> firstAirDate
|
||||||
},
|
},
|
||||||
popularity = popularity,
|
popularity = popularity,
|
||||||
|
voteCount = voteCount,
|
||||||
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
|
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -468,6 +477,7 @@ object TmdbCollectionSourceResolver {
|
||||||
TmdbCollectionMediaType.TV -> firstAirDate
|
TmdbCollectionMediaType.TV -> firstAirDate
|
||||||
},
|
},
|
||||||
popularity = popularity,
|
popularity = popularity,
|
||||||
|
voteCount = voteCount,
|
||||||
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
|
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -508,6 +518,7 @@ object TmdbCollectionSourceResolver {
|
||||||
when (sortBy) {
|
when (sortBy) {
|
||||||
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
|
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
|
||||||
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
|
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
|
TmdbCollectionSort.VOTE_COUNT_DESC.value -> TmdbCollectionSort.VOTE_COUNT_DESC.value
|
||||||
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
|
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
else -> sortBy
|
else -> sortBy
|
||||||
}
|
}
|
||||||
|
|
@ -516,6 +527,7 @@ object TmdbCollectionSourceResolver {
|
||||||
when (sortBy) {
|
when (sortBy) {
|
||||||
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
|
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
|
||||||
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
|
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
|
TmdbCollectionSort.VOTE_COUNT_DESC.value -> TmdbCollectionSort.VOTE_COUNT_DESC.value
|
||||||
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
|
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
|
||||||
else -> sortBy
|
else -> sortBy
|
||||||
}
|
}
|
||||||
|
|
@ -640,6 +652,7 @@ private data class TmdbPersonCreditCast(
|
||||||
@SerialName("release_date") val releaseDate: String? = null,
|
@SerialName("release_date") val releaseDate: String? = null,
|
||||||
@SerialName("first_air_date") val firstAirDate: String? = null,
|
@SerialName("first_air_date") val firstAirDate: String? = null,
|
||||||
@SerialName("vote_average") val voteAverage: Double? = null,
|
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||||
|
@SerialName("vote_count") val voteCount: Int? = null,
|
||||||
val popularity: Double? = null,
|
val popularity: Double? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -658,6 +671,7 @@ private data class TmdbPersonCreditCrew(
|
||||||
@SerialName("first_air_date") val firstAirDate: String? = null,
|
@SerialName("first_air_date") val firstAirDate: String? = null,
|
||||||
val job: String? = null,
|
val job: String? = null,
|
||||||
@SerialName("vote_average") val voteAverage: Double? = null,
|
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||||
|
@SerialName("vote_count") val voteCount: Int? = null,
|
||||||
val popularity: Double? = null,
|
val popularity: Double? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -675,6 +689,7 @@ private data class TmdbListItem(
|
||||||
@SerialName("release_date") val releaseDate: String? = null,
|
@SerialName("release_date") val releaseDate: String? = null,
|
||||||
@SerialName("first_air_date") val firstAirDate: String? = null,
|
@SerialName("first_air_date") val firstAirDate: String? = null,
|
||||||
@SerialName("vote_average") val voteAverage: Double? = null,
|
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||||
|
@SerialName("vote_count") val voteCount: Int? = null,
|
||||||
val popularity: Double? = null,
|
val popularity: Double? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -687,5 +702,6 @@ private data class TmdbCollectionPart(
|
||||||
@SerialName("backdrop_path") val backdropPath: String? = null,
|
@SerialName("backdrop_path") val backdropPath: String? = null,
|
||||||
@SerialName("release_date") val releaseDate: String? = null,
|
@SerialName("release_date") val releaseDate: String? = null,
|
||||||
@SerialName("vote_average") val voteAverage: Double? = null,
|
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||||
|
@SerialName("vote_count") val voteCount: Int? = null,
|
||||||
val popularity: Double? = null,
|
val popularity: Double? = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}\"||\"\"]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.nuvio.app.features.debrid
|
||||||
|
|
||||||
|
internal fun encodePathSegment(value: String): String =
|
||||||
|
percentEncode(value, spaceAsPlus = false)
|
||||||
|
|
||||||
|
internal fun encodeFormValue(value: String): String =
|
||||||
|
percentEncode(value, spaceAsPlus = true)
|
||||||
|
|
||||||
|
internal fun queryString(vararg pairs: Pair<String, String?>): String =
|
||||||
|
pairs
|
||||||
|
.mapNotNull { (key, value) ->
|
||||||
|
value?.let { "${encodePathSegment(key)}=${encodePathSegment(it)}" }
|
||||||
|
}
|
||||||
|
.joinToString("&")
|
||||||
|
|
||||||
|
private fun percentEncode(value: String, spaceAsPlus: Boolean): String = buildString {
|
||||||
|
val hex = "0123456789ABCDEF"
|
||||||
|
value.encodeToByteArray().forEach { byte ->
|
||||||
|
val code = byte.toInt() and 0xFF
|
||||||
|
val isUnreserved = (code in 'A'.code..'Z'.code) ||
|
||||||
|
(code in 'a'.code..'z'.code) ||
|
||||||
|
(code in '0'.code..'9'.code) ||
|
||||||
|
code == '-'.code ||
|
||||||
|
code == '.'.code ||
|
||||||
|
code == '_'.code ||
|
||||||
|
code == '~'.code
|
||||||
|
when {
|
||||||
|
isUnreserved -> append(code.toChar())
|
||||||
|
spaceAsPlus && code == 0x20 -> append('+')
|
||||||
|
else -> {
|
||||||
|
append('%')
|
||||||
|
append(hex[code shr 4])
|
||||||
|
append(hex[code and 0x0F])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -80,10 +80,12 @@ fun DetailMetaInfo(
|
||||||
val runtimeText = formatRuntimeForDisplay(meta.runtime)
|
val runtimeText = formatRuntimeForDisplay(meta.runtime)
|
||||||
val ageBadge = meta.ageRating?.trim()?.takeIf { it.isNotBlank() }
|
val ageBadge = meta.ageRating?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val hasMdbImdbRating = meta.externalRatings.any { it.source == PROVIDER_IMDB }
|
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 ||
|
val hasMetaRow = releaseLine != null ||
|
||||||
runtimeText != null ||
|
runtimeText != null ||
|
||||||
ageBadge != null ||
|
ageBadge != null ||
|
||||||
(meta.imdbRating != null && !hasMdbImdbRating)
|
(validImdbRating != null && !hasMdbImdbRating)
|
||||||
if (hasMetaRow) {
|
if (hasMetaRow) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -108,7 +110,7 @@ fun DetailMetaInfo(
|
||||||
ageBadge?.let { badge ->
|
ageBadge?.let { badge ->
|
||||||
DetailHeroMetaBadge(text = badge)
|
DetailHeroMetaBadge(text = badge)
|
||||||
}
|
}
|
||||||
if (meta.imdbRating != null && !hasMdbImdbRating) {
|
if (validImdbRating != null && !hasMdbImdbRating) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|
@ -129,7 +131,7 @@ fun DetailMetaInfo(
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(5.dp))
|
Spacer(modifier = Modifier.width(5.dp))
|
||||||
Text(
|
Text(
|
||||||
text = meta.imdbRating,
|
text = validImdbRating,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = ImdbYellow,
|
color = ImdbYellow,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ data class MetaPreview(
|
||||||
val releaseInfo: String? = null,
|
val releaseInfo: String? = null,
|
||||||
val rawReleaseDate: String? = null,
|
val rawReleaseDate: String? = null,
|
||||||
val popularity: Double? = null,
|
val popularity: Double? = null,
|
||||||
|
val voteCount: Int? = null,
|
||||||
val imdbRating: String? = null,
|
val imdbRating: String? = null,
|
||||||
val genres: List<String> = emptyList(),
|
val genres: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -597,7 +597,7 @@ private fun EpisodeStreamsSubView(
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = streams,
|
items = streams,
|
||||||
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
|
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
|
||||||
) { _, stream ->
|
) { _, stream ->
|
||||||
EpisodeSourceStreamRow(
|
EpisodeSourceStreamRow(
|
||||||
stream = stream,
|
stream = stream,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.nuvio.app.core.ui.NuvioToastController
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
|
||||||
|
import com.nuvio.app.features.debrid.toastMessage
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.AddonResource
|
import com.nuvio.app.features.addons.AddonResource
|
||||||
import com.nuvio.app.features.addons.ManagedAddon
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
|
|
@ -802,7 +806,55 @@ fun PlayerScreen(
|
||||||
playerController?.seekTo(targetPositionMs)
|
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) {
|
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
|
val url = stream.directPlaybackUrl ?: return
|
||||||
if (url == activeSourceUrl) return
|
if (url == activeSourceUrl) return
|
||||||
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
|
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
|
||||||
|
|
@ -844,6 +896,26 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
|
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
|
val url = stream.directPlaybackUrl ?: return
|
||||||
showNextEpisodeCard = false
|
showNextEpisodeCard = false
|
||||||
showSourcesPanel = false
|
showSourcesPanel = false
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ fun PlayerSourcesPanel(
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = streams,
|
items = streams,
|
||||||
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
|
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
|
||||||
) { _, stream ->
|
) { _, stream ->
|
||||||
val isCurrent = isCurrentStream(
|
val isCurrent = isCurrentStream(
|
||||||
stream = stream,
|
stream = stream,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridStreamSource
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.plugins.PluginRepository
|
import com.nuvio.app.features.plugins.PluginRepository
|
||||||
import com.nuvio.app.features.plugins.pluginContentId
|
import com.nuvio.app.features.plugins.pluginContentId
|
||||||
|
|
@ -152,6 +154,10 @@ object PlayerStreamsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
val installedAddons = AddonRepository.uiState.value.addons
|
val installedAddons = AddonRepository.uiState.value.addons
|
||||||
|
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
|
||||||
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
|
val playerSettings = PlayerSettingsRepository.uiState.value
|
||||||
|
val debridTargets = DirectDebridStreamSource.configuredTargets()
|
||||||
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
|
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
|
||||||
PluginRepository.initialize()
|
PluginRepository.initialize()
|
||||||
PluginRepository.getEnabledScrapersForType(type)
|
PluginRepository.getEnabledScrapersForType(type)
|
||||||
|
|
@ -159,7 +165,7 @@ object PlayerStreamsRepository {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
|
if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
|
||||||
stateFlow.value = StreamsUiState(
|
stateFlow.value = StreamsUiState(
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
|
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(
|
stateFlow.value = StreamsUiState(
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
|
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
|
||||||
|
|
@ -207,6 +213,13 @@ object PlayerStreamsRepository {
|
||||||
streams = emptyList(),
|
streams = emptyList(),
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
)
|
)
|
||||||
|
} + debridTargets.map { target ->
|
||||||
|
AddonStreamGroup(
|
||||||
|
addonName = target.addonName,
|
||||||
|
addonId = target.addonId,
|
||||||
|
streams = emptyList(),
|
||||||
|
isLoading = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
stateFlow.value = StreamsUiState(
|
stateFlow.value = StreamsUiState(
|
||||||
groups = initialGroups,
|
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 ->
|
jobs.forEach { deferred ->
|
||||||
val result = deferred.await()
|
val result = deferred.await()
|
||||||
stateFlow.update { current ->
|
stateFlow.update { current ->
|
||||||
|
|
@ -293,6 +317,28 @@ object PlayerStreamsRepository {
|
||||||
} else null,
|
} 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)
|
setJob(job)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
package com.nuvio.app.features.settings
|
package com.nuvio.app.features.settings
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.CloudQueue
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
|
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment
|
import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment
|
||||||
import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description
|
import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_integrations_debrid_description
|
||||||
import nuvio.composeapp.generated.resources.settings_integrations_section_title
|
import nuvio.composeapp.generated.resources.settings_integrations_section_title
|
||||||
import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description
|
import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
@ -13,6 +17,7 @@ internal fun LazyListScope.integrationsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
onTmdbClick: () -> Unit,
|
onTmdbClick: () -> Unit,
|
||||||
onMdbListClick: () -> Unit,
|
onMdbListClick: () -> Unit,
|
||||||
|
onDebridClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
|
|
@ -35,6 +40,14 @@ internal fun LazyListScope.integrationsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onMdbListClick,
|
onClick = onMdbListClick,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = stringResource(Res.string.compose_settings_page_debrid),
|
||||||
|
description = stringResource(Res.string.settings_integrations_debrid_description),
|
||||||
|
icon = Icons.Rounded.CloudQueue,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onClick = onDebridClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_account
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_addons
|
import nuvio.composeapp.generated.resources.compose_settings_page_addons
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
|
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
|
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
|
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
|
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
|
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
|
||||||
|
|
@ -129,6 +130,11 @@ internal enum class SettingsPage(
|
||||||
category = SettingsCategory.General,
|
category = SettingsCategory.General,
|
||||||
parentPage = Integrations,
|
parentPage = Integrations,
|
||||||
),
|
),
|
||||||
|
Debrid(
|
||||||
|
titleRes = Res.string.compose_settings_page_debrid,
|
||||||
|
category = SettingsCategory.General,
|
||||||
|
parentPage = Integrations,
|
||||||
|
),
|
||||||
TraktAuthentication(
|
TraktAuthentication(
|
||||||
titleRes = Res.string.compose_settings_page_trakt,
|
titleRes = Res.string.compose_settings_page_trakt,
|
||||||
category = SettingsCategory.Account,
|
category = SettingsCategory.Account,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleUiState
|
import com.nuvio.app.core.ui.PosterCardStyleUiState
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettings
|
||||||
|
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||||
import com.nuvio.app.features.home.HomeCatalogSettingsItem
|
import com.nuvio.app.features.home.HomeCatalogSettingsItem
|
||||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.mdblist.MdbListSettings
|
import com.nuvio.app.features.mdblist.MdbListSettings
|
||||||
|
|
@ -127,6 +129,10 @@ fun SettingsScreen(
|
||||||
MdbListSettingsRepository.ensureLoaded()
|
MdbListSettingsRepository.ensureLoaded()
|
||||||
MdbListSettingsRepository.uiState
|
MdbListSettingsRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val debridSettings by remember {
|
||||||
|
DebridSettingsRepository.ensureLoaded()
|
||||||
|
DebridSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val traktAuthUiState by remember {
|
val traktAuthUiState by remember {
|
||||||
TraktAuthRepository.ensureLoaded()
|
TraktAuthRepository.ensureLoaded()
|
||||||
TraktAuthRepository.uiState
|
TraktAuthRepository.uiState
|
||||||
|
|
@ -232,6 +238,7 @@ fun SettingsScreen(
|
||||||
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
||||||
tmdbSettings = tmdbSettings,
|
tmdbSettings = tmdbSettings,
|
||||||
mdbListSettings = mdbListSettings,
|
mdbListSettings = mdbListSettings,
|
||||||
|
debridSettings = debridSettings,
|
||||||
traktAuthUiState = traktAuthUiState,
|
traktAuthUiState = traktAuthUiState,
|
||||||
traktCommentsEnabled = traktCommentsEnabled,
|
traktCommentsEnabled = traktCommentsEnabled,
|
||||||
traktSettingsUiState = traktSettingsUiState,
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
|
|
@ -279,6 +286,7 @@ fun SettingsScreen(
|
||||||
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
||||||
tmdbSettings = tmdbSettings,
|
tmdbSettings = tmdbSettings,
|
||||||
mdbListSettings = mdbListSettings,
|
mdbListSettings = mdbListSettings,
|
||||||
|
debridSettings = debridSettings,
|
||||||
traktAuthUiState = traktAuthUiState,
|
traktAuthUiState = traktAuthUiState,
|
||||||
traktCommentsEnabled = traktCommentsEnabled,
|
traktCommentsEnabled = traktCommentsEnabled,
|
||||||
traktSettingsUiState = traktSettingsUiState,
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
|
|
@ -336,6 +344,7 @@ private fun MobileSettingsScreen(
|
||||||
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
||||||
tmdbSettings: TmdbSettings,
|
tmdbSettings: TmdbSettings,
|
||||||
mdbListSettings: MdbListSettings,
|
mdbListSettings: MdbListSettings,
|
||||||
|
debridSettings: DebridSettings,
|
||||||
traktAuthUiState: TraktAuthUiState,
|
traktAuthUiState: TraktAuthUiState,
|
||||||
traktCommentsEnabled: Boolean,
|
traktCommentsEnabled: Boolean,
|
||||||
traktSettingsUiState: TraktSettingsUiState,
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
|
|
@ -544,6 +553,7 @@ private fun MobileSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
||||||
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
|
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
|
||||||
|
onDebridClick = { onPageChange(SettingsPage.Debrid) },
|
||||||
)
|
)
|
||||||
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
|
|
@ -553,6 +563,10 @@ private fun MobileSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
settings = mdbListSettings,
|
settings = mdbListSettings,
|
||||||
)
|
)
|
||||||
|
SettingsPage.Debrid -> debridSettingsContent(
|
||||||
|
isTablet = false,
|
||||||
|
settings = debridSettings,
|
||||||
|
)
|
||||||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
uiState = traktAuthUiState,
|
uiState = traktAuthUiState,
|
||||||
|
|
@ -637,6 +651,7 @@ private fun TabletSettingsScreen(
|
||||||
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
||||||
tmdbSettings: TmdbSettings,
|
tmdbSettings: TmdbSettings,
|
||||||
mdbListSettings: MdbListSettings,
|
mdbListSettings: MdbListSettings,
|
||||||
|
debridSettings: DebridSettings,
|
||||||
traktAuthUiState: TraktAuthUiState,
|
traktAuthUiState: TraktAuthUiState,
|
||||||
traktCommentsEnabled: Boolean,
|
traktCommentsEnabled: Boolean,
|
||||||
traktSettingsUiState: TraktSettingsUiState,
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
|
|
@ -904,6 +919,7 @@ private fun TabletSettingsScreen(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
||||||
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
|
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
|
||||||
|
onDebridClick = { onPageChange(SettingsPage.Debrid) },
|
||||||
)
|
)
|
||||||
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
|
|
@ -913,6 +929,10 @@ private fun TabletSettingsScreen(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
settings = mdbListSettings,
|
settings = mdbListSettings,
|
||||||
)
|
)
|
||||||
|
SettingsPage.Debrid -> debridSettingsContent(
|
||||||
|
isTablet = true,
|
||||||
|
settings = debridSettings,
|
||||||
|
)
|
||||||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
uiState = traktAuthUiState,
|
uiState = traktAuthUiState,
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,14 @@ object StreamAutoPlaySelector {
|
||||||
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
|
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
|
||||||
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
|
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
|
||||||
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
|
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
|
||||||
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.directPlaybackUrl != null
|
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable()
|
||||||
}
|
}
|
||||||
if (bingeGroupMatch != null) return bingeGroupMatch
|
if (bingeGroupMatch != null) return bingeGroupMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (mode) {
|
return when (mode) {
|
||||||
StreamAutoPlayMode.MANUAL -> null
|
StreamAutoPlayMode.MANUAL -> null
|
||||||
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.directPlaybackUrl != null }
|
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable() }
|
||||||
StreamAutoPlayMode.REGEX_MATCH -> {
|
StreamAutoPlayMode.REGEX_MATCH -> {
|
||||||
val pattern = regexPattern.trim()
|
val pattern = regexPattern.trim()
|
||||||
|
|
||||||
|
|
@ -61,7 +61,8 @@ object StreamAutoPlaySelector {
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val matchingStreams = candidateStreams.filter { stream ->
|
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 {
|
val searchableText = buildString {
|
||||||
append(stream.addonName).append(' ')
|
append(stream.addonName).append(' ')
|
||||||
|
|
@ -81,8 +82,11 @@ object StreamAutoPlaySelector {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchingStreams.isEmpty()) return null
|
if (matchingStreams.isEmpty()) return null
|
||||||
matchingStreams.firstOrNull { it.directPlaybackUrl != null }
|
matchingStreams.firstOrNull { it.isAutoPlayable() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun StreamItem.isAutoPlayable(): Boolean =
|
||||||
|
directPlaybackUrl != null || isDirectDebridStream
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,18 @@ import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
data class StreamItem(
|
data class StreamItem(
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val url: String? = null,
|
val url: String? = null,
|
||||||
val infoHash: String? = null,
|
val infoHash: String? = null,
|
||||||
val fileIdx: Int? = null,
|
val fileIdx: Int? = null,
|
||||||
val externalUrl: String? = null,
|
val externalUrl: String? = null,
|
||||||
|
val sources: List<String> = emptyList(),
|
||||||
val sourceName: String? = null,
|
val sourceName: String? = null,
|
||||||
val addonName: String,
|
val addonName: String,
|
||||||
val addonId: String,
|
val addonId: String,
|
||||||
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
|
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
|
||||||
|
val clientResolve: StreamClientResolve? = null,
|
||||||
) {
|
) {
|
||||||
val streamLabel: String
|
val streamLabel: String
|
||||||
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
|
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
|
||||||
|
|
@ -25,13 +28,18 @@ data class StreamItem(
|
||||||
val directPlaybackUrl: String?
|
val directPlaybackUrl: String?
|
||||||
get() = url ?: externalUrl
|
get() = url ?: externalUrl
|
||||||
|
|
||||||
|
val isDirectDebridStream: Boolean
|
||||||
|
get() = clientResolve?.isDirectDebridCandidate == true
|
||||||
|
|
||||||
val isTorrentStream: Boolean
|
val isTorrentStream: Boolean
|
||||||
get() = !infoHash.isNullOrBlank() ||
|
get() = !isDirectDebridStream && (
|
||||||
|
!infoHash.isNullOrBlank() ||
|
||||||
url.isMagnetLink() ||
|
url.isMagnetLink() ||
|
||||||
externalUrl.isMagnetLink()
|
externalUrl.isMagnetLink()
|
||||||
|
)
|
||||||
|
|
||||||
val hasPlayableSource: Boolean
|
val hasPlayableSource: Boolean
|
||||||
get() = url != null || infoHash != null || externalUrl != null
|
get() = url != null || infoHash != null || externalUrl != null || clientResolve != null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String?.isMagnetLink(): Boolean =
|
private fun String?.isMagnetLink(): Boolean =
|
||||||
|
|
@ -40,6 +48,7 @@ private fun String?.isMagnetLink(): Boolean =
|
||||||
data class StreamBehaviorHints(
|
data class StreamBehaviorHints(
|
||||||
val bingeGroup: String? = null,
|
val bingeGroup: String? = null,
|
||||||
val notWebReady: Boolean = false,
|
val notWebReady: Boolean = false,
|
||||||
|
val videoHash: String? = null,
|
||||||
val videoSize: Long? = null,
|
val videoSize: Long? = null,
|
||||||
val filename: String? = null,
|
val filename: String? = null,
|
||||||
val proxyHeaders: StreamProxyHeaders? = null,
|
val proxyHeaders: StreamProxyHeaders? = null,
|
||||||
|
|
@ -50,6 +59,71 @@ data class StreamProxyHeaders(
|
||||||
val response: Map<String, String>? = null,
|
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(
|
data class AddonStreamGroup(
|
||||||
val addonName: String,
|
val addonName: String,
|
||||||
val addonId: String,
|
val addonId: String,
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,10 @@ object StreamParser {
|
||||||
val url = obj.string("url")
|
val url = obj.string("url")
|
||||||
val infoHash = obj.string("infoHash")
|
val infoHash = obj.string("infoHash")
|
||||||
val externalUrl = obj.string("externalUrl")
|
val externalUrl = obj.string("externalUrl")
|
||||||
|
val clientResolve = obj.objectValue("clientResolve")?.toClientResolve()
|
||||||
|
|
||||||
// Must have at least one playable source
|
// 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 hintsObj = obj["behaviorHints"] as? JsonObject
|
||||||
val proxyHeaders = hintsObj
|
val proxyHeaders = hintsObj
|
||||||
|
|
@ -36,16 +37,20 @@ object StreamParser {
|
||||||
?.toProxyHeaders()
|
?.toProxyHeaders()
|
||||||
StreamItem(
|
StreamItem(
|
||||||
name = obj.string("name"),
|
name = obj.string("name"),
|
||||||
|
title = obj.string("title"),
|
||||||
description = obj.string("description") ?: obj.string("title"),
|
description = obj.string("description") ?: obj.string("title"),
|
||||||
url = url,
|
url = url,
|
||||||
infoHash = infoHash,
|
infoHash = infoHash,
|
||||||
fileIdx = obj.int("fileIdx"),
|
fileIdx = obj.int("fileIdx"),
|
||||||
externalUrl = externalUrl,
|
externalUrl = externalUrl,
|
||||||
|
sources = obj.stringList("sources"),
|
||||||
addonName = addonName,
|
addonName = addonName,
|
||||||
addonId = addonId,
|
addonId = addonId,
|
||||||
|
clientResolve = clientResolve,
|
||||||
behaviorHints = StreamBehaviorHints(
|
behaviorHints = StreamBehaviorHints(
|
||||||
bingeGroup = hintsObj?.string("bingeGroup"),
|
bingeGroup = hintsObj?.string("bingeGroup"),
|
||||||
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
|
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
|
||||||
|
videoHash = hintsObj?.string("videoHash"),
|
||||||
videoSize = hintsObj?.long("videoSize"),
|
videoSize = hintsObj?.long("videoSize"),
|
||||||
filename = hintsObj?.string("filename"),
|
filename = hintsObj?.string("filename"),
|
||||||
proxyHeaders = proxyHeaders,
|
proxyHeaders = proxyHeaders,
|
||||||
|
|
@ -58,10 +63,14 @@ object StreamParser {
|
||||||
this[name]?.jsonPrimitive?.contentOrNull
|
this[name]?.jsonPrimitive?.contentOrNull
|
||||||
|
|
||||||
private fun JsonObject.int(name: String): Int? =
|
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? =
|
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? =
|
private fun JsonObject.boolean(name: String): Boolean? =
|
||||||
this[name]?.jsonPrimitive?.booleanOrNull
|
this[name]?.jsonPrimitive?.booleanOrNull
|
||||||
|
|
@ -69,6 +78,16 @@ object StreamParser {
|
||||||
private fun JsonObject.objectValue(name: String): JsonObject? =
|
private fun JsonObject.objectValue(name: String): JsonObject? =
|
||||||
this[name] as? 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> =
|
private fun JsonObject.stringMap(): Map<String, String> =
|
||||||
entries.mapNotNull { (key, value) ->
|
entries.mapNotNull { (key, value) ->
|
||||||
(value as? JsonPrimitive)?.contentOrNull
|
(value as? JsonPrimitive)?.contentOrNull
|
||||||
|
|
@ -87,4 +106,68 @@ object StreamParser {
|
||||||
response = responseHeaders,
|
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"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
|
||||||
|
import com.nuvio.app.features.debrid.DirectDebridStreamSource
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.plugins.PluginRepository
|
import com.nuvio.app.features.plugins.PluginRepository
|
||||||
|
|
@ -14,6 +16,7 @@ import com.nuvio.app.features.plugins.PluginRepositoryItem
|
||||||
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
||||||
import com.nuvio.app.features.plugins.PluginScraper
|
import com.nuvio.app.features.plugins.PluginScraper
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
@ -131,6 +134,7 @@ object StreamsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
val installedAddons = AddonRepository.uiState.value.addons
|
val installedAddons = AddonRepository.uiState.value.addons
|
||||||
|
val debridTargets = DirectDebridStreamSource.configuredTargets()
|
||||||
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
|
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
|
||||||
PluginRepository.getEnabledScrapersForType(type)
|
PluginRepository.getEnabledScrapersForType(type)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -141,7 +145,7 @@ object StreamsRepository {
|
||||||
groupByRepository = pluginUiState.groupStreamsByRepository,
|
groupByRepository = pluginUiState.groupStreamsByRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) {
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
requestToken = requestToken,
|
requestToken = requestToken,
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
|
|
@ -170,7 +174,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
|
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(
|
_uiState.value = StreamsUiState(
|
||||||
requestToken = requestToken,
|
requestToken = requestToken,
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
|
|
@ -194,6 +198,13 @@ object StreamsRepository {
|
||||||
streams = emptyList(),
|
streams = emptyList(),
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
)
|
)
|
||||||
|
} + debridTargets.map { target ->
|
||||||
|
AddonStreamGroup(
|
||||||
|
addonName = target.addonName,
|
||||||
|
addonId = target.addonId,
|
||||||
|
streams = emptyList(),
|
||||||
|
isLoading = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
requestToken = requestToken,
|
requestToken = requestToken,
|
||||||
|
|
@ -211,13 +222,21 @@ object StreamsRepository {
|
||||||
.associate { it.addonId to it.scrapers.size }
|
.associate { it.addonId to it.scrapers.size }
|
||||||
.toMutableMap()
|
.toMutableMap()
|
||||||
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
|
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
|
||||||
val totalTasks = streamAddons.size + pluginRemainingByAddonId.values.sum()
|
val totalTasks = streamAddons.size +
|
||||||
|
pluginProviderGroups.sumOf { it.scrapers.size } +
|
||||||
|
debridTargets.size
|
||||||
|
|
||||||
val installedAddonNames = installedAddons
|
val installedAddonNames = installedAddons
|
||||||
.map { it.displayTitle }
|
.map { it.displayTitle }
|
||||||
.toSet()
|
.toSet()
|
||||||
var autoSelectTriggered = false
|
var autoSelectTriggered = false
|
||||||
var timeoutElapsed = 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 timeoutJob = if (isAutoPlayEnabled) {
|
||||||
val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L
|
val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L
|
||||||
|
|
@ -271,7 +290,7 @@ object StreamsRepository {
|
||||||
log.d { "Fetching streams from: $url" }
|
log.d { "Fetching streams from: $url" }
|
||||||
|
|
||||||
val displayName = addon.addonName
|
val displayName = addon.addonName
|
||||||
val group = runCatching {
|
val group = runCatchingUnlessCancelled {
|
||||||
val payload = httpGetText(url)
|
val payload = httpGetText(url)
|
||||||
StreamParser.parse(
|
StreamParser.parse(
|
||||||
payload = payload,
|
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) {
|
repeat(totalTasks) {
|
||||||
when (val completion = completions.receive()) {
|
when (val completion = completions.receive()) {
|
||||||
is StreamLoadCompletion.Addon -> {
|
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) {
|
if (isAutoPlayEnabled && !autoSelectTriggered) {
|
||||||
autoSelectTriggered = true
|
autoSelectTriggered = true
|
||||||
val allStreams = _uiState.value.groups.flatMap { it.streams }
|
val allStreams = _uiState.value.groups.flatMap { it.streams }
|
||||||
|
|
@ -493,6 +561,7 @@ private data class PluginProviderGroup(
|
||||||
|
|
||||||
private sealed interface StreamLoadCompletion {
|
private sealed interface StreamLoadCompletion {
|
||||||
data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion
|
data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion
|
||||||
|
data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion
|
||||||
data class PluginScraper(
|
data class PluginScraper(
|
||||||
val addonId: String,
|
val addonId: String,
|
||||||
val streams: List<StreamItem>,
|
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(
|
private fun PluginRuntimeResult.toStreamItem(
|
||||||
scraper: PluginScraper,
|
scraper: PluginScraper,
|
||||||
addonName: String = scraper.name,
|
addonName: String = scraper.name,
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,7 @@ private fun LazyListScope.streamSection(
|
||||||
StreamCard(
|
StreamCard(
|
||||||
stream = stream,
|
stream = stream,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (stream.directPlaybackUrl != null || stream.isTorrentStream) {
|
if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) {
|
||||||
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
|
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -896,7 +896,7 @@ internal fun streamCardRenderKey(
|
||||||
append(':')
|
append(':')
|
||||||
append(itemIndex)
|
append(itemIndex)
|
||||||
append(':')
|
append(':')
|
||||||
append(stream.url ?: stream.infoHash ?: stream.streamLabel)
|
append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -970,7 +970,7 @@ private fun StreamCard(
|
||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream
|
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream
|
||||||
val cardShape = RoundedCornerShape(12.dp)
|
val cardShape = RoundedCornerShape(12.dp)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -145,16 +145,49 @@ class StreamAutoPlaySelectorTest {
|
||||||
assertNull(selected)
|
assertNull(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first stream mode can select direct debrid candidate without resolved URL`() {
|
||||||
|
val directDebrid = stream(
|
||||||
|
addonName = "Torbox Instant",
|
||||||
|
url = null,
|
||||||
|
name = "TB Instant",
|
||||||
|
directDebrid = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
|
||||||
|
streams = listOf(directDebrid),
|
||||||
|
mode = StreamAutoPlayMode.FIRST_STREAM,
|
||||||
|
regexPattern = "",
|
||||||
|
source = StreamAutoPlaySource.ALL_SOURCES,
|
||||||
|
installedAddonNames = emptySet(),
|
||||||
|
selectedAddons = emptySet(),
|
||||||
|
selectedPlugins = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(directDebrid, selected)
|
||||||
|
}
|
||||||
|
|
||||||
private fun stream(
|
private fun stream(
|
||||||
addonName: String,
|
addonName: String,
|
||||||
url: String? = null,
|
url: String? = null,
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
bingeGroup: String? = null,
|
bingeGroup: String? = null,
|
||||||
|
directDebrid: Boolean = false,
|
||||||
): StreamItem = StreamItem(
|
): StreamItem = StreamItem(
|
||||||
name = name,
|
name = name,
|
||||||
url = url,
|
url = url,
|
||||||
addonName = addonName,
|
addonName = addonName,
|
||||||
addonId = addonName,
|
addonId = addonName,
|
||||||
|
clientResolve = if (directDebrid) {
|
||||||
|
StreamClientResolve(
|
||||||
|
type = "debrid",
|
||||||
|
service = "torbox",
|
||||||
|
isCached = true,
|
||||||
|
infoHash = "hash",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
behaviorHints = StreamBehaviorHints(
|
behaviorHints = StreamBehaviorHints(
|
||||||
bingeGroup = bingeGroup,
|
bingeGroup = bingeGroup,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -119,4 +119,56 @@ class StreamParserTest {
|
||||||
assertEquals("video/mp4", responseHeaders["content-type"])
|
assertEquals("video/mp4", responseHeaders["content-type"])
|
||||||
assertEquals("ok", responseHeaders["x-test"])
|
assertEquals("ok", responseHeaders["x-test"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse keeps client resolve metadata without direct URL`() {
|
||||||
|
val streams = StreamParser.parse(
|
||||||
|
payload =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"name": "Instant",
|
||||||
|
"clientResolve": {
|
||||||
|
"type": "debrid",
|
||||||
|
"infoHash": "abc123",
|
||||||
|
"fileIdx": 4,
|
||||||
|
"sources": ["udp://tracker.example"],
|
||||||
|
"torrentName": "Movie Pack",
|
||||||
|
"filename": "Movie.2024.2160p.mkv",
|
||||||
|
"service": "torbox",
|
||||||
|
"isCached": true,
|
||||||
|
"stream": {
|
||||||
|
"raw": {
|
||||||
|
"size": 1610612736,
|
||||||
|
"indexer": "Indexer",
|
||||||
|
"parsed": {
|
||||||
|
"parsed_title": "Movie",
|
||||||
|
"year": 2024,
|
||||||
|
"resolution": "2160p",
|
||||||
|
"hdr": ["DV"],
|
||||||
|
"audio": ["Atmos"],
|
||||||
|
"episodes": [1, 2],
|
||||||
|
"bit_depth": "10bit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
addonName = "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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=59
|
CURRENT_PROJECT_VERSION=62
|
||||||
MARKETING_VERSION=0.1.19
|
MARKETING_VERSION=0.1.20
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue