Compare commits

..

No commits in common. "dev" and "trakt" have entirely different histories.
dev ... trakt

398 changed files with 10118 additions and 129447 deletions

View file

@ -1,220 +0,0 @@
name: Bug report
description: Report a reproducible bug (one per issue).
title: "[Bug]: short summary here"
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug.
If we can reproduce it, we can usually fix it. This form is just to get the basics in one place.
Please replace the default title with a short summary of the actual problem.
If the app crashes, logs are required. Crash reports without logs may be labeled `needs-info`.
- type: markdown
attributes:
value: |
## Quick checks
- type: checkboxes
id: checks
attributes:
label: Pre-flight checks
options:
- label: I searched existing issues and this is not a duplicate.
required: true
- label: I can reproduce this on the latest release or latest main build.
required: false
- label: This issue is limited to a single bug (not multiple unrelated problems).
required: true
- type: markdown
attributes:
value: |
## Version & device
- type: input
id: app_version
attributes:
label: App version / commit
description: Release version (e.g. 1.2.3) or commit hash. If unsure, say where you got the APK/build from.
placeholder: "e.g. 1.2.3, or main@abc1234"
validations:
required: true
- type: dropdown
id: install_method
attributes:
label: Install method
options:
- GitHub Release APK
- CI build / Nightly
- Built from source
- Other (please describe below)
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- Android TV
- Google TV
- Fire TV
- Android phone/tablet
- Android emulator
- Other (please describe below)
validations:
required: true
- type: input
id: device_model
attributes:
label: Device model
description: "Example: Chromecast 4K, Shield TV, Mi Box, Fire TV Stick 4K, Pixel 8, etc."
placeholder: "e.g. NVIDIA Shield TV (2019)"
validations:
required: true
- type: input
id: android_version
attributes:
label: Android version
placeholder: "e.g. Android 12"
validations:
required: true
- type: dropdown
id: player_mode
attributes:
label: Player mode
description: If you are using an external player, most playback issues must be reported to that player instead.
options:
- Internal player
- External player
- Ask every time
- Not sure
validations:
required: true
- type: markdown
attributes:
value: |
## What happened?
- type: dropdown
id: area
attributes:
label: Area (tag)
description: Pick the closest match. It helps triage.
options:
- Playback (start/stop/buffering)
- Streams / Sources (selection, loading, errors)
- Next Episode / Auto-play
- Watch Progress (resume, watched state, history)
- Subtitles (download, styling, sync)
- Audio tracks
- Focus / Remote navigation
- UI / Layout
- Settings
- Sync (Trakt / remote)
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
placeholder: |
1. Open ...
2. Navigate to ...
3. Press ...
4. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
placeholder: "What you expected to happen."
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
placeholder: "What actually happened (include any on-screen error text/codes)."
validations:
required: true
- type: dropdown
id: frequency
attributes:
label: Frequency
options:
- Always
- Often (more than 50%)
- Sometimes
- Rarely
- Once
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Did this work before?
options:
- Not sure
- Yes, it used to work
- No, it never worked
validations:
required: true
- type: markdown
attributes:
value: |
## Extra context (optional)
- type: textarea
id: media_details
attributes:
label: Media details (optional)
description: Only include what you can safely share.
placeholder: |
- Content type: series/movie
- Season/Episode: S1E2
- Stream/source: (addon name / source label)
- Video format: (if known)
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs (required for crash reports)
description: |
Required if the app crashes or force closes.
For other bug reports, logs are optional but still helpful.
Include a short snippet from around the time the bug happens.
render: shell
placeholder: |
adb logcat -d | tail -n 300
validations:
required: false
- type: textarea
id: extra
attributes:
label: Anything else? (optional)
description: Screenshots/recordings, related issues, workarounds, etc.
validations:
required: false

View file

@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Downloads / Releases
url: https://github.com/tapframe/NuvioTV/releases
about: Grab the latest GitHub Release APK here.
- name: Documentation
url: https://github.com/tapframe/NuvioTV/blob/main/README.md
about: Read the README for setup and usage details.

View file

@ -1,78 +0,0 @@
name: Feature request
description: Suggest an improvement or new feature.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
One feature request per issue, please. The more real-world your use case is, the easier it is to evaluate.
Feature requests are reviewed as product proposals first.
Please do not open a pull request for a new feature, major UX change, or broad cosmetic update unless a maintainer has explicitly approved it first.
Unapproved feature PRs will usually be closed.
- type: dropdown
id: area
attributes:
label: Area (tag)
options:
- Playback
- Streams / Sources
- Next Episode / Auto-play
- Watch Progress
- Subtitles
- Audio
- Focus / Remote navigation
- UI / Layout
- Settings
- Sync (Trakt / remote)
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem statement
description: What problem are you trying to solve?
placeholder: "I want to be able to..."
validations:
required: true
- type: textarea
id: proposed
attributes:
label: Proposed solution
description: What would you like the app to do?
validations:
required: true
- type: dropdown
id: contribution_plan
attributes:
label: Are you planning to implement this yourself?
description: Major features are usually implemented in-house unless approved first.
options:
- No, this is only a proposal
- Maybe, but only if approved first
- Yes, but I understand implementation still needs maintainer approval
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered (optional)
description: Any workarounds or other approaches you considered.
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context (optional)
description: Mockups, examples from other apps, etc.
validations:
required: false

View file

@ -1,42 +0,0 @@
## Summary
<!-- What changed in this PR? -->
## PR type
<!-- Pick one and delete the others -->
- Bug fix
- Small maintenance improvement
- Docs fix
- Translation update
- Approved larger change (link approval below)
## Why
<!-- Why this change is needed. Link bug/issue/context. -->
## Policy check
<!-- Confirm these before requesting review -->
- [ ] This PR is not cosmetic-only, unless it is a translation PR.
- [ ] This PR does not add a new major feature without prior approval.
- [ ] This PR is small in scope and focused on one problem.
- [ ] If this is a larger or directional change, I linked the issue where it was approved.
<!-- PRs that do not match this policy will usually be closed without merge. -->
## Testing
<!-- What you tested and how (manual + automated). -->
## Screenshots / Video (UI changes only)
<!-- If UI changed, add before/after screenshots or a short clip. -->
## Breaking changes
<!-- Any breaking behavior/config/schema changes? If none, write: None -->
## Linked issues
<!-- Example: Fixes #123 -->

View file

@ -1,182 +0,0 @@
name: Beta Release
on:
workflow_dispatch:
inputs:
release_mode:
description: "Choose whether to preview, save as draft, or publish immediately"
required: true
default: dry_run
type: choice
options:
- dry_run
- draft
- publish
manual_release:
description: "Use manual tag/title without changing Gradle version"
required: true
default: false
type: boolean
version:
description: "Target versionName for auto mode, for example 0.4.19-beta"
required: false
type: string
version_code:
description: "Optional versionCode override for auto mode. Defaults to current versionCode + 1."
required: false
type: string
release_tag:
description: "Manual mode tag. Required when manual release is enabled."
required: false
type: string
release_title:
description: "Manual mode GitHub release title. Required when manual release is enabled."
required: false
type: string
downloader_code:
description: "Optional downloader code footer"
required: false
type: string
custom_release_notes:
description: "Optional full Markdown release notes. When set, it replaces generated notes entirely."
required: false
type: string
extra_notes:
description: "Optional extra bullet lines, one per line"
required: false
type: string
permissions:
contents: write
concurrency:
group: beta-release-${{ github.ref }}
cancel-in-progress: false
jobs:
beta-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Prepare local properties
env:
LOCAL_PROPERTIES_BASE64: ${{ secrets.LOCAL_PROPERTIES_BASE64 }}
LOCAL_DEV_PROPERTIES_BASE64: ${{ secrets.LOCAL_DEV_PROPERTIES_BASE64 }}
run: |
if [ -n "$LOCAL_PROPERTIES_BASE64" ]; then
printf '%s' "$LOCAL_PROPERTIES_BASE64" | base64 --decode > local.properties
fi
if [ -n "$LOCAL_DEV_PROPERTIES_BASE64" ]; then
printf '%s' "$LOCAL_DEV_PROPERTIES_BASE64" | base64 --decode > local.dev.properties
fi
- name: Prepare signing config
if: ${{ inputs.release_mode != 'dry_run' }}
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.NUVIO_RELEASE_KEYSTORE_BASE64 }}
RELEASE_KEY_ALIAS: ${{ secrets.NUVIO_RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.NUVIO_RELEASE_KEY_PASSWORD }}
RELEASE_STORE_PASSWORD: ${{ secrets.NUVIO_RELEASE_STORE_PASSWORD }}
run: |
if [ -n "$RELEASE_KEYSTORE_BASE64" ]; then
echo "$RELEASE_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/nuviotv.jks"
{
echo "NUVIO_RELEASE_STORE_FILE=$RUNNER_TEMP/nuviotv.jks"
echo "NUVIO_RELEASE_KEY_ALIAS=$RELEASE_KEY_ALIAS"
echo "NUVIO_RELEASE_KEY_PASSWORD=$RELEASE_KEY_PASSWORD"
echo "NUVIO_RELEASE_STORE_PASSWORD=$RELEASE_STORE_PASSWORD"
} >> "$GITHUB_ENV"
else
echo "CI_USE_DEBUG_SIGNING=true" >> "$GITHUB_ENV"
fi
- name: Preview beta release
if: ${{ inputs.release_mode == 'dry_run' }}
run: |
args=(python3 scripts/release_beta.py --dry-run)
if [ "${{ inputs.manual_release }}" = "true" ]; then
args+=(--manual-release --release-tag "${{ inputs.release_tag }}" --release-title "${{ inputs.release_title }}")
else
args+=("${{ inputs.version }}")
if [ -n "${{ inputs.version_code }}" ]; then
args+=(--version-code "${{ inputs.version_code }}")
fi
if [ -n "${{ inputs.release_tag }}" ]; then
args+=(--release-tag "${{ inputs.release_tag }}")
fi
if [ -n "${{ inputs.release_title }}" ]; then
args+=(--release-title "${{ inputs.release_title }}")
fi
fi
if [ -n "${{ inputs.custom_release_notes }}" ]; then
printf '%s\n' '${{ inputs.custom_release_notes }}' > "$RUNNER_TEMP/custom-release-notes.md"
args+=(--custom-notes-file "$RUNNER_TEMP/custom-release-notes.md")
fi
if [ -n "${{ inputs.downloader_code }}" ]; then
args+=(--downloader-code "${{ inputs.downloader_code }}")
fi
if [ -n "${{ inputs.extra_notes }}" ]; then
args+=(--extra-notes "${{ inputs.extra_notes }}")
fi
"${args[@]}"
- name: Upload preview notes
if: ${{ inputs.release_mode == 'dry_run' }}
uses: actions/upload-artifact@v4
with:
name: beta-release-preview-${{ inputs.manual_release && inputs.release_tag || inputs.version }}
path: build/release/release-notes-${{ inputs.manual_release && inputs.release_tag || inputs.version }}.md
- name: Build beta release
if: ${{ inputs.release_mode != 'dry_run' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
args=(python3 scripts/release_beta.py)
case "${{ inputs.release_mode }}" in
draft) args+=(--draft) ;;
publish) args+=(--publish) ;;
esac
if [ "${{ inputs.manual_release }}" = "true" ]; then
args+=(--manual-release --release-tag "${{ inputs.release_tag }}" --release-title "${{ inputs.release_title }}")
else
args+=("${{ inputs.version }}")
if [ -n "${{ inputs.version_code }}" ]; then
args+=(--version-code "${{ inputs.version_code }}")
fi
if [ -n "${{ inputs.release_tag }}" ]; then
args+=(--release-tag "${{ inputs.release_tag }}")
fi
if [ -n "${{ inputs.release_title }}" ]; then
args+=(--release-title "${{ inputs.release_title }}")
fi
fi
if [ -n "${{ inputs.custom_release_notes }}" ]; then
printf '%s\n' '${{ inputs.custom_release_notes }}' > "$RUNNER_TEMP/custom-release-notes.md"
args+=(--custom-notes-file "$RUNNER_TEMP/custom-release-notes.md")
fi
if [ -n "${{ inputs.downloader_code }}" ]; then
args+=(--downloader-code "${{ inputs.downloader_code }}")
fi
if [ -n "${{ inputs.extra_notes }}" ]; then
args+=(--extra-notes "${{ inputs.extra_notes }}")
fi
"${args[@]}"

View file

@ -1,83 +0,0 @@
name: Close unlabeled issues
on:
schedule:
- cron: "23 6 * * *" # daily
workflow_dispatch:
inputs:
dry_run:
description: Log matching issues without commenting or closing them
required: false
type: boolean
default: false
permissions:
issues: write
jobs:
close_unlabeled:
runs-on: ubuntu-latest
steps:
- name: Close open issues with no labels
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const inputs = context.payload.inputs || {};
const dryRun = String(inputs.dry_run || "false").toLowerCase() === "true";
const closeMarker = "<!-- nuviotv-bot:close-unlabeled -->";
const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
q: `repo:${owner}/${repo} is:issue is:open no:label`,
per_page: 100,
});
core.info(`Found ${items.length} open unlabeled issues.`);
for (const item of items) {
const issueNumber = item.number;
if (dryRun) {
core.info(`#${issueNumber}: would comment and close.`);
continue;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyCommented = comments.some(comment =>
(comment.body || "").includes(closeMarker)
);
if (!alreadyCommented) {
const body =
`${closeMarker}\n` +
`Sorry about the churn here, and thanks for taking the time to report this.\n\n` +
`Closing this issue because it does not have any labels.\n\n` +
`Issue labels and triage rules were introduced later, so older unlabeled issues are no longer tracked reliably.\n\n` +
`If you are still facing this problem, please open a new issue using the appropriate issue template and label so it can be triaged correctly.`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
}
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: "not_planned",
});
core.info(`#${issueNumber}: closed.`);
}

View file

@ -1,75 +0,0 @@
name: PR Template Check
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
validate_pr_body:
runs-on: ubuntu-latest
steps:
- name: Validate required PR sections
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const body = (pr.body || "").trim();
function sectionContent(title) {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^##\\s+${escaped}\\s*$`, "m");
const match = body.match(re);
if (!match) return null;
const start = match.index + match[0].length;
const rest = body.slice(start);
const next = rest.search(/^##\s+/m);
return (next === -1 ? rest : rest.slice(0, next)).trim();
}
const required = ["Summary", "Why", "Testing", "Breaking changes", "Linked issues"];
const missing = [];
const empty = [];
for (const name of required) {
const content = sectionContent(name);
if (content === null) {
missing.push(name);
continue;
}
const cleaned = content
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/`/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
if (
cleaned.length < 4 ||
cleaned === "none" ||
cleaned.includes("what changed in this pr") ||
cleaned.includes("why this change is needed") ||
cleaned.includes("what you tested") ||
cleaned.includes("example: fixes #123")
) {
empty.push(name);
}
}
if (missing.length || empty.length) {
const lines = [
"PR description is missing required detail.",
"",
];
if (missing.length) lines.push(`Missing sections: ${missing.join(", ")}`);
if (empty.length) lines.push(`Incomplete sections: ${empty.join(", ")}`);
lines.push("");
lines.push("Please fill the PR template before merging.");
core.setFailed(lines.join("\n"));
} else {
core.info("PR template check passed.");
}

View file

@ -1,75 +0,0 @@
name: Close stale needs-info issues
on:
schedule:
- cron: "17 6 * * *" # daily
workflow_dispatch: {}
permissions:
issues: write
jobs:
close_stale:
runs-on: ubuntu-latest
steps:
- name: Warn then close inactive needs-info
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const NEEDS_INFO = "needs-info";
const WARN_AFTER_DAYS = 14;
const CLOSE_AFTER_DAYS = 21;
const warnMarker = "<!-- nuviotv-bot:stale-warning -->";
const closeMarker = "<!-- nuviotv-bot:stale-close -->";
const now = Date.now();
const warnCutoff = now - WARN_AFTER_DAYS * 24 * 60 * 60 * 1000;
const closeCutoff = now - CLOSE_AFTER_DAYS * 24 * 60 * 60 * 1000;
async function listOpenNeedsInfoIssues() {
// Search API is the simplest way to find issues by label.
const q = `repo:${owner}/${repo} is:issue is:open label:"${NEEDS_INFO}"`;
const res = await github.rest.search.issuesAndPullRequests({ q, per_page: 50 });
return res.data.items || [];
}
const items = await listOpenNeedsInfoIssues();
for (const item of items) {
const issue_number = item.number;
// Use updated_at (includes comments/edits) as the inactivity signal.
const updatedAtMs = new Date(item.updated_at).getTime();
// Fetch comments to see if we already warned/closed.
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const hasWarned = comments.some(c => (c.body || "").includes(warnMarker));
const hasClosedComment = comments.some(c => (c.body || "").includes(closeMarker));
if (updatedAtMs <= closeCutoff && hasWarned && !hasClosedComment) {
const body =
`${closeMarker}\n` +
`Closing this for now since we didn't get the requested details.\n\n` +
`If you can share the missing info, reply here and we can reopen.`;
await github.rest.issues.createComment({ owner, repo, issue_number, body });
await github.rest.issues.update({ owner, repo, issue_number, state: "closed" });
continue;
}
if (updatedAtMs <= warnCutoff && !hasWarned) {
const body =
`${warnMarker}\n` +
`Just a quick ping: this issue is labeled \`${NEEDS_INFO}\` and hasn't had any updates in a bit.\n\n` +
`If you can add the missing details, we can keep going. Otherwise it may be closed after a grace period.`;
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
}

View file

@ -1,164 +0,0 @@
name: Triage (needs-info)
on:
issues:
types: [opened, edited, reopened]
permissions:
issues: write
jobs:
needs_info:
runs-on: ubuntu-latest
steps:
- name: Label low-context issues
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.issue.number;
const issue = context.payload.issue;
const title = (issue.title || "").trim();
const body = issue.body || "";
const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase());
const NEEDS_INFO = "needs-info";
const NEEDS_INFO_COLOR = "d4c5f9"; // light purple (GitHub-ish)
const NEEDS_INFO_DESC = "More details needed to reproduce / triage.";
function hasLabel(name) {
return labels.includes(name.toLowerCase());
}
function extractSection(title) {
// Issue forms render headings like "### Steps to reproduce"
const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
const match = body.match(re);
if (!match) return "";
const start = match.index + match[0].length;
const rest = body.slice(start);
const next = rest.search(/^###\s+/m);
const section = (next === -1 ? rest : rest.slice(0, next));
return section.trim();
}
function extractFirstSection(titles) {
for (const sectionTitle of titles) {
const value = extractSection(sectionTitle);
if (value) return value;
}
return "";
}
function normalizeText(value) {
return (value || "").replace(/\s+/g, " ").trim();
}
function stripIssuePrefix(value) {
return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim();
}
const steps = extractSection("Steps to reproduce");
const expected = extractSection("Expected behavior");
const actual = extractSection("Actual behavior");
const logs = extractFirstSection([
"Logs (required for crash reports)",
"Logs (optional but helpful)",
]);
const extra = extractSection("Anything else? (optional)");
const summaryTitle = stripIssuePrefix(title);
// Only triage bug-style issues.
// Skip feature/enhancement issues so they don't get "missing repro" comments.
const looksLikeBugForm = !!(steps || expected || actual);
const isBugIssue = hasLabel("bug") || looksLikeBugForm;
const isFeatureIssue =
hasLabel("enhancement") ||
hasLabel("feature") ||
hasLabel("feature request");
if (!isBugIssue || isFeatureIssue) {
return;
}
// Keep checks simple and forgiving: required fields can still be low-effort (e.g. "doesn't work").
const problems = [];
const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i;
const numericOnlyTitle = /^#?\d+$/;
const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i;
const crashContext = [summaryTitle, steps, actual, extra].map(normalizeText).join("\n");
const isCrashIssue = crashPattern.test(crashContext);
const normalizedLogs = normalizeText(logs);
const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs);
if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) {
problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)");
}
if (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)");
if (!expected || expected.length < 10) problems.push("Expected behavior");
if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)");
if (isCrashIssue && !hasLogs) {
problems.push("Logs (required for crash reports; include a short logcat snippet or stack trace)");
}
async function ensureLabel(name, color, description) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
// Create if missing.
try {
await github.rest.issues.createLabel({ owner, repo, name, color, description });
} catch (_) {
// Ignore if it was created concurrently or permissions differ.
}
}
}
const hasNeedsInfo = hasLabel(NEEDS_INFO);
if (problems.length > 0) {
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
if (!hasNeedsInfo) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [NEEDS_INFO],
});
}
const marker = "<!-- nuviotv-bot:needs-info -->";
const commentBody =
`${marker}\n` +
`Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` +
`Missing / too short:\n` +
problems.map(p => `- ${p}`).join("\n") +
`\n\n` +
`Use a specific title, for example: \`[Bug]: Continue Watching shows percent instead of time left\`.\n` +
`${isCrashIssue ? `Crash reports must include logs.\n` : `Logcat is optional for most issues, but it helps a lot.\n`}` +
`Example: \`adb logcat -d | tail -n 300\``;
// Avoid spamming duplicate bot comments on every edit.
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const alreadyCommented = comments.some(c => (c.body || "").includes(marker));
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: commentBody,
});
}
} else if (hasNeedsInfo) {
// If the user filled things in later, remove needs-info automatically.
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: NEEDS_INFO });
} catch (_) {}
}

8
.gitignore vendored
View file

@ -37,12 +37,8 @@ benchmark
db/
backups/
.supabase_db.env
scripts/*
!scripts/release_beta.py
/supabase
scripts
supabase
tv-samples
HOME_UI_OPTIMIZATION_NOTES.local.md
.kotlin/
bugs/
avatars/
logs

View file

@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-03-30T03:43:38.199872Z">
<DropdownSelection timestamp="2026-02-03T18:03:33.281364Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amarjitsingh/.android/avd/Television_1080p.avd" />
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/nayifnoushad/.android/avd/Television_1080p.avd" />
</handle>
</Target>
</DropdownSelection>

View file

@ -11,7 +11,6 @@
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/baselineprofile" />
</set>
</option>
</GradleProjectSettings>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View file

@ -2,5 +2,17 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Player" vcs="Git" />
<mapping directory="$PROJECT_DIR$/android-tv-samples" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/ass" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/expat" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/fontconfig" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/freetype" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/freetype/subprojects/dlg" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/fribidi" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/harfbuzz" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/unibreak" vcs="Git" />
</component>
</project>

View file

@ -1,81 +0,0 @@
# Contributing
Thanks for helping improve NuvioTV.
## PR policy
Pull requests are currently intended for:
- Reproducible bug fixes
- Small stability improvements
- Minor maintenance work
- Small documentation fixes that improve accuracy
- Translation updates
Pull requests are generally **not** accepted for:
- New major features
- Product direction changes
- Large UX / UI redesigns
- Cosmetic-only changes
- Refactors without a clear user-facing or maintenance benefit
For feature ideas and bigger changes, please open an issue first. Feature implementation is usually kept in-house unless it has been discussed and explicitly approved beforehand.
Translation PRs are allowed, as long as they stay focused on translation/localization work and do not bundle unrelated feature or UI changes.
## Where to ask questions
- Use **Issues** for bugs, feature requests, setup help, and general support.
## Bug reports (rules)
To keep issues fixable, bug reports should include:
- A short, specific issue title that describes the bug
- App version (release version or commit hash)
- Platform + device model + Android version
- Install method (release APK / CI / built from source)
- Steps to reproduce (exact steps)
- Expected vs actual behavior
- Frequency (always/sometimes/once)
Do not leave the title as just `[Bug]:` or another generic placeholder.
Logcat is optional for most issues, but it is **required** for crash / force-close reports.
### How to capture logs (optional)
If you can, reproduce the issue once, then attach a short log snippet from around the time it happened:
```sh
adb logcat -d | tail -n 300
```
If the issue is a crash, include a stack trace or log snippet from Android Studio or `adb logcat`.
## Feature requests (rules)
Please include:
- The problem you are solving (use case)
- Your proposed solution
- Alternatives considered (if any)
Opening a feature request does **not** mean a pull request will be accepted for it. If the feature affects product scope, UX direction, or adds a significant new surface area, do not start implementation unless a maintainer explicitly approves it first.
## Before opening a PR
Please make sure your PR is all of the following:
- Small in scope
- Focused on one problem
- Clearly aligned with the current direction of the project
- Not cosmetic-only, unless it is a translation PR
- Not a new major feature unless it was discussed and approved first
PRs that do not fit this policy will usually be closed without merge so review time can stay focused on bugs, regressions, and small improvements.
## One issue per problem
Please open separate issues for separate bugs/features. It makes tracking, fixing, and closing issues much faster.

View file

@ -1,4 +1,4 @@
plugins {
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
@ -6,6 +6,7 @@
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
id("io.sentry.android.gradle") version "6.0.0"
}
import java.util.Properties
@ -24,28 +25,16 @@ val devProperties = Properties().apply {
}
}
fun env(name: String): String? = providers.environmentVariable(name).orNull
val useDebugReleaseSigning = env("CI_USE_DEBUG_SIGNING").equals("true", ignoreCase = true)
val releaseStoreFilePath = env("NUVIO_RELEASE_STORE_FILE")
?: localProperties.getProperty("NUVIO_RELEASE_STORE_FILE")
val releaseKeyAliasValue = env("NUVIO_RELEASE_KEY_ALIAS")
?: localProperties.getProperty("NUVIO_RELEASE_KEY_ALIAS", "nuviotv")
val releaseKeyPasswordValue = env("NUVIO_RELEASE_KEY_PASSWORD")
?: localProperties.getProperty("NUVIO_RELEASE_KEY_PASSWORD", "815787")
val releaseStorePasswordValue = env("NUVIO_RELEASE_STORE_PASSWORD")
?: localProperties.getProperty("NUVIO_RELEASE_STORE_PASSWORD", "815787")
android {
namespace = "com.nuvio.tv"
compileSdk = 36
defaultConfig {
applicationId = "com.nuvio.tv"
minSdk = 24
minSdk = 26
targetSdk = 36
versionCode = 54
versionName = "0.6.3-beta"
versionCode = 23
versionName = "0.4.5-beta"
buildConfigField("String", "PARENTAL_GUIDE_API_URL", "\"${localProperties.getProperty("PARENTAL_GUIDE_API_URL", "")}\"")
buildConfigField("String", "INTRODB_API_URL", "\"${localProperties.getProperty("INTRODB_API_URL", "")}\"")
@ -55,12 +44,7 @@ android {
buildConfigField("String", "TRAKT_CLIENT_ID", "\"${localProperties.getProperty("TRAKT_CLIENT_ID", "")}\"")
buildConfigField("String", "TRAKT_CLIENT_SECRET", "\"${localProperties.getProperty("TRAKT_CLIENT_SECRET", "")}\"")
buildConfigField("String", "TRAKT_API_URL", "\"${localProperties.getProperty("TRAKT_API_URL", "https://api.trakt.tv/")}\"")
buildConfigField("String", "TRAKT_REDIRECT_URI", "\"${localProperties.getProperty("TRAKT_REDIRECT_URI", "urn:ietf:wg:oauth:2.0:oob")}\"")
buildConfigField("String", "TMDB_API_KEY", "\"${localProperties.getProperty("TMDB_API_KEY", "")}\"")
buildConfigField("String", "TV_LOGIN_WEB_BASE_URL", "\"${localProperties.getProperty("TV_LOGIN_WEB_BASE_URL", "https://app.nuvio.tv/tv-login")}\"")
buildConfigField("String", "DONATIONS_BASE_URL", "\"${localProperties.getProperty("DONATIONS_BASE_URL", "")}\"")
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${localProperties.getProperty("DONATIONS_DONATE_URL", "")}\"")
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", "")}\"")
// In-app updater (GitHub Releases)
buildConfigField("String", "GITHUB_OWNER", "\"tapframe\"")
@ -69,10 +53,10 @@ android {
signingConfigs {
create("release") {
keyAlias = releaseKeyAliasValue
keyPassword = releaseKeyPasswordValue
storeFile = releaseStoreFilePath?.let(::file) ?: file("../nuviotv.jks")
storePassword = releaseStorePasswordValue
keyAlias = "nuviotv"
keyPassword = "815787"
storeFile = file("../nuviotv.jks")
storePassword = "815787"
}
}
@ -93,22 +77,14 @@ android {
buildConfigField("String", "TRAILER_API_URL", "\"${devProperties.getProperty("TRAILER_API_URL", "")}\"")
buildConfigField("String", "IMDB_RATINGS_API_BASE_URL", "\"${devProperties.getProperty("IMDB_RATINGS_API_BASE_URL", "")}\"")
buildConfigField("String", "IMDB_TAPFRAME_API_BASE_URL", "\"${devProperties.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}\"")
buildConfigField("String", "DONATIONS_BASE_URL", "\"${devProperties.getProperty("DONATIONS_BASE_URL", localProperties.getProperty("DONATIONS_BASE_URL", ""))}\"")
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${devProperties.getProperty("DONATIONS_DONATE_URL", localProperties.getProperty("DONATIONS_DONATE_URL", ""))}\"")
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${devProperties.getProperty("AVATAR_PUBLIC_BASE_URL", localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", ""))}\"")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = if (useDebugReleaseSigning) {
signingConfigs.getByName("debug")
} else {
signingConfigs.getByName("release")
}
signingConfig = signingConfigs.getByName("release")
buildConfigField("boolean", "IS_DEBUG_BUILD", "false")
@ -121,22 +97,6 @@ android {
buildConfigField("String", "TRAILER_API_URL", "\"${localProperties.getProperty("TRAILER_API_URL", "")}\"")
buildConfigField("String", "IMDB_RATINGS_API_BASE_URL", "\"${localProperties.getProperty("IMDB_RATINGS_API_BASE_URL", "")}\"")
buildConfigField("String", "IMDB_TAPFRAME_API_BASE_URL", "\"${localProperties.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}\"")
buildConfigField("String", "DONATIONS_BASE_URL", "\"${localProperties.getProperty("DONATIONS_BASE_URL", "")}\"")
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${localProperties.getProperty("DONATIONS_DONATE_URL", "")}\"")
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", "")}\"")
}
create("benchmark") {
initWith(buildTypes.getByName("release"))
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
applicationIdSuffix = ".debug"
matchingFallbacks += "release"
}
}
@ -152,7 +112,6 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
@ -163,35 +122,6 @@ android {
compose = true
buildConfig = true
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
packaging {
jniLibs {
// Keep one consistent native set across dependencies.
pickFirsts += listOf(
"lib/*/libc++_shared.so",
"lib/*/libavcodec.so",
"lib/*/libavdevice.so",
"lib/*/libavfilter.so",
"lib/*/libavformat.so",
"lib/*/libavutil.so",
"lib/*/libswscale.so",
"lib/*/libswresample.so",
"lib/*/libtorrserver.so"
)
}
}
}
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.applicationId.set("com.nuviodebug.com")
}
}
composeCompiler {
@ -207,21 +137,10 @@ configurations.all {
exclude(group = "androidx.media3", module = "media3-ui")
}
baselineProfile {
automaticGenerationDuringBuild = false
saveInSrc = true
mergeIntoMain = true
baselineProfileOutputDir = "src/main"
filter {
include("com.nuvio.tv.**")
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
baselineProfile(project(":baselineprofile"))
// baselineProfile(project(":benchmark")) // TODO: create benchmark module later
implementation(libs.androidx.core.ktx)
implementation("androidx.core:core-splashscreen:1.0.1")
implementation(libs.androidx.appcompat)
@ -257,7 +176,6 @@ dependencies {
// Image Loading
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.svg)
// Navigation
@ -296,9 +214,6 @@ dependencies {
// libass-android for ASS/SSA subtitle support (from Maven Central)
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
// Local nextlib-mediainfo fork (static FFmpeg; no libav*.so in final AAR)
implementation(files("libs/nextlib-mediainfo-local.aar"))
implementation("io.github.abdallahmehiz:mpv-android-lib:0.1.12")
implementation("dev.chrisbanes.haze:haze-android:0.7.3") {
exclude(group = "org.jetbrains.compose.ui")
exclude(group = "org.jetbrains.compose.foundation")
@ -309,26 +224,6 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.gson)
// Jackson — required by CloudStream DEX extensions at runtime
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
// NiceHTTP — HTTP client used by CloudStream extensions via `app.get()`
implementation(libs.nicehttp)
// Conscrypt — TLS provider with browser-compatible fingerprint for Cloudflare bypass
implementation(libs.conscrypt.android)
// CloudStream library — provides core API classes for extension compatibility
implementation(libs.cloudstream.library) {
// Exclude heavy deps we don't need or that conflict
exclude(group = "org.mozilla", module = "rhino")
exclude(group = "com.github.AmarullisVFX", module = "newpipeextractor")
exclude(group = "com.github.AmaryllisVFX", module = "newpipeextractor")
exclude(group = "com.github.AmaryllisVFX.newpipeextractor")
exclude(group = "info.debatty", module = "java-string-similarity")
}
// Markdown rendering
implementation(libs.markdown.renderer.m3)
@ -338,7 +233,6 @@ dependencies {
implementation(libs.nanohttpd)
implementation(libs.zxing.core)
// Supabase
implementation(platform(libs.supabase.bom))
implementation(libs.supabase.auth)
@ -349,8 +243,8 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
// Performance profiling
implementation("androidx.metrics:metrics-performance:1.0.0-rc01") // JankStats
debugImplementation("androidx.compose.runtime:runtime-tracing")
implementation("androidx.metrics:metrics-performance:1.0.0-beta01") // JankStats
implementation("androidx.compose.runtime:runtime-tracing") // Compose function names in Perfetto
// Bundle real crypto-js (JS) for QuickJS plugins
implementation("org.webjars.npm:crypto-js:4.2.0")

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -25,8 +25,6 @@
# Keep Retrofit service interfaces (must preserve generic return types)
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
# NOTE: allowobfuscation here is fine for Retrofit, but superseded by the
# broader kotlin.** keep rule below for DexClassLoader extension compatibility.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Keep all project API interfaces
-keep class com.nuvio.tv.data.remote.api.** { *; }
@ -55,9 +53,6 @@
# Keep server classes and their inner data classes (serialized with Gson)
-keep class com.nuvio.tv.core.server.** { *; }
# ── Torrent streaming (TorrServer) ─────────────────────────────────────────────
-keep class com.nuvio.tv.core.torrent.** { *; }
#── QuickJS ────────────────────────────────────────────────────────────────────
# Keep quickjs-kt library classes for proper type conversion
-keep class com.dokar.quickjs.** { *; }
@ -88,45 +83,7 @@
kotlinx.serialization.KSerializer serializer(...);
}
# ── External extension compatibility stubs (loaded via DexClassLoader) ────────
-keep class com.lagradost.cloudstream3.** { *; }
-keepclassmembers class com.lagradost.cloudstream3.** { *; }
-keep class com.lagradost.nicehttp.** { *; }
-keepclassmembers class com.lagradost.nicehttp.** { *; }
-keep class com.lagradost.api.** { *; }
-keepclassmembers class com.lagradost.api.** { *; }
# ── General ────────────────────────────────────────────────────────────────────
# Keep line numbers for crash reports
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# MPV (native JNI callbacks)
# Native code reflects into multiple classes/methods under is.xyz.mpv,
# so keep the whole package to avoid JNI lookup crashes after R8.
-keep class is.xyz.mpv.** { *; }
# ── Missing class stubs (referenced by cloudstream3 / jsoup / newpipe) ────────
-dontwarn org.mozilla.javascript.**
-dontwarn com.google.re2j.**
-dontwarn javax.script.**
-dontwarn okhttp3.internal.sse.**
-dontwarn org.jsoup.helper.Re2jRegex
# ── DexClassLoader runtime deps (CloudStream extensions) ─────────────────────
# Extensions are DEX files loaded at runtime via DexClassLoader. They resolve
# dependencies by fully-qualified name from the host classloader. R8 must not
# rename or remove any class that extensions may reference.
-keep class kotlin.** { *; }
-keep class kotlinx.coroutines.** { *; }
-keep class okhttp3.** { *; }
-keepclassmembers class okhttp3.** { *; }
-keep class okio.** { *; }
-keepclassmembers class okio.** { *; }
-keep class org.jsoup.** { *; }
-keepclassmembers class org.jsoup.** { *; }
-keep class com.fasterxml.jackson.** { *; }
-keepclassmembers class com.fasterxml.jackson.** { *; }
-dontwarn java.beans.ConstructorProperties
-dontwarn java.beans.Transient

View file

@ -1,3 +0,0 @@
<resources>
<string name="app_name">Nuvio Debug</string>
</resources>

View file

@ -3,7 +3,6 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -20,13 +19,18 @@
android:allowBackup="true"
android:banner="@mipmap/banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:label="Nuvio"
android:largeHeap="true"
android:extractNativeLibs="true"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:localeConfig="@xml/locale_config"
android:theme="@style/Theme.MyApplication">
<meta-data
android:name="io.sentry.dsn"
android:value="https://151aefbf907d5fd4a6c8533d9d064441@o4510894222278656.ingest.us.sentry.io/4510894227259392" />
<meta-data
android:name="io.sentry.send-default-pii"
android:value="true" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@ -40,7 +44,6 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|uiMode|keyboard|keyboardHidden|navigation|layoutDirection|fontScale|locale"
android:theme="@style/Theme.MyApplication.Launcher">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

File diff suppressed because it is too large Load diff

View file

@ -1,76 +0,0 @@
@file:Suppress("unused")
package com.lagradost.cloudstream3
import android.app.Activity
import android.content.Context
import com.lagradost.api.setContext
import java.lang.ref.WeakReference
/**
* Stub for CloudStream's AcraApplication.
* Extensions (e.g. Yflix) reference AcraApplication.context and the getKey/setKey helpers.
* We provide no-op implementations since we don't need extension settings persistence.
*/
open class AcraApplication {
companion object {
/** Application context stub. Extensions use this for PackageManager etc. */
@JvmStatic
var context: Context? = null
set(value) {
field = value
// Also set the library's context so WebViewResolver and other
// library components can access it
if (value != null) {
setContext(WeakReference(value))
}
}
/**
* Weak reference to the current Activity. CloudStream extensions
* often require a non-null Activity in their load() method to
* register MainAPIs. Set from MainActivity.onCreate().
*/
private var activityRef: WeakReference<Activity>? = null
@JvmStatic
fun getActivity(): Activity? = activityRef?.get()
@JvmStatic
fun setActivity(activity: Activity?) {
activityRef = if (activity != null) WeakReference(activity) else null
// Update the library's context to the Activity (preferred for WebView)
if (activity != null) {
setContext(WeakReference(activity))
}
}
/** Retrieve a stored value. No-op — always returns the default. */
@JvmStatic
fun <T> getKey(path: String, key: String, default: T? = null): T? = default
/** Retrieve a stored value (single-key overload). */
@JvmStatic
fun <T> getKey(key: String, default: T? = null): T? = default
/** Store a value. No-op. */
@JvmStatic
fun setKey(path: String, key: String, value: Any?) {}
/** Store a value (single-key overload). No-op. */
@JvmStatic
fun setKey(key: String, value: Any?) {}
/** Remove stored keys by prefix. No-op. */
@JvmStatic
fun removeKeys(prefix: String) {}
/** Remove a single stored key. No-op. */
@JvmStatic
fun removeKey(path: String, key: String) {}
/** Remove a single stored key (single-key overload). No-op. */
@JvmStatic
fun removeKey(key: String) {}
}
}

View file

@ -1,14 +0,0 @@
@file:Suppress("unused")
package com.lagradost.cloudstream3
import android.app.Activity
/** Stub for CommonActivity referenced by some extensions. */
object CommonActivity {
var activity: Activity? = null
fun showToast(message: String, duration: Int = 0) {
// No-op stub — extensions may call this for user notifications
}
}

View file

@ -1,160 +0,0 @@
package com.lagradost.cloudstream3.network
import android.util.Log
import android.webkit.CookieManager
import androidx.annotation.AnyThread
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.app
import kotlinx.coroutines.runBlocking
import okhttp3.Headers
import okhttp3.Headers.Companion.toHeaders
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.net.URI
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
fun getHeaders(
headers: Map<String, String>,
cookie: Map<String, String>
): Headers {
val cookieMap =
if (cookie.isNotEmpty()) mapOf(
"Cookie" to cookie.entries.joinToString(" ") {
"${it.key}=${it.value};"
}) else mapOf()
val tempHeaders = (DEFAULT_HEADERS + headers + cookieMap)
return tempHeaders.toHeaders()
}
/**
* CloudflareKiller bypasses Cloudflare challenges using WebView.
* Matches CloudStream's real implementation from RequestsHelper.kt + CloudflareKiller.kt.
*/
@AnyThread
class CloudflareKiller : Interceptor {
companion object {
const val TAG = "CloudflareKiller"
private val ERROR_CODES = listOf(403, 503)
private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare")
fun parseCookieMap(cookie: String): Map<String, String> {
return cookie.split(";").associate {
val split = it.split("=")
(split.getOrNull(0)?.trim() ?: "") to (split.getOrNull(1)?.trim() ?: "")
}.filter { it.key.isNotBlank() && it.value.isNotBlank() }
}
}
init {
try {
CookieManager.getInstance().removeAllCookies(null)
} catch (_: Exception) { }
}
val savedCookies: MutableMap<String, Map<String, String>> = mutableMapOf()
fun getCookieHeaders(url: String): Headers {
val userAgentHeaders = WebViewResolver.webViewUserAgent?.let {
mapOf("user-agent" to it)
} ?: emptyMap()
return getHeaders(userAgentHeaders, savedCookies[URI(url).host] ?: emptyMap())
}
override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
val request = chain.request()
when (val cookies = savedCookies[request.url.host]) {
null -> {
val response = chain.proceed(request)
if (!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) {
return@runBlocking response
} else {
response.close()
bypassCloudflare(request)?.let {
Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
return@runBlocking it
}
}
}
else -> {
return@runBlocking proceed(request, cookies)
}
}
Log.w(TAG, "Failed cloudflare at: ${request.url}")
return@runBlocking chain.proceed(request)
}
private fun getWebViewCookie(url: String): String? {
return try {
CookieManager.getInstance()?.getCookie(url)
} catch (_: Exception) { null }
}
private fun trySolveWithSavedCookies(request: Request): Boolean {
return getWebViewCookie(request.url.toString())?.let { cookie ->
cookie.contains("cf_clearance").also { solved ->
if (solved) {
Log.d(TAG, "Found cf_clearance cookie for ${request.url.host}")
savedCookies[request.url.host] = parseCookieMap(cookie)
}
}
} ?: false
}
private suspend fun proceed(request: Request, cookies: Map<String, String>): Response {
val userAgentMap = WebViewResolver.getWebViewUserAgent()?.let {
mapOf("user-agent" to it)
} ?: emptyMap()
val requestCookies = request.header("Cookie")?.let { parseCookieMap(it) } ?: emptyMap()
val headers = getHeaders(
request.headers.toMap() + userAgentMap,
cookies + requestCookies
)
return app.baseClient.newCall(
request.newBuilder()
.headers(headers)
.build()
).execute()
}
private suspend fun bypassCloudflare(request: Request): Response? {
val url = request.url.toString()
if (!trySolveWithSavedCookies(request)) {
Log.d(TAG, "Loading webview to solve cloudflare for ${request.url}")
try {
WebViewResolver(
interceptUrl = Regex(".^"),
userAgent = null,
useOkhttp = false,
additionalUrls = listOf(Regex("."))
).resolveUsingWebView(url) {
trySolveWithSavedCookies(request)
}
} catch (e: Exception) {
Log.e(TAG, "WebView bypass failed: ${e.message}")
return null
}
}
val cookies = savedCookies[request.url.host]
if (cookies == null) {
Log.w(TAG, "No cf_clearance obtained for ${request.url.host}")
return null
}
return proceed(request, cookies)
}
private fun Headers.toMap(): Map<String, String> {
return buildMap {
for (i in 0 until size) {
put(name(i), value(i))
}
}
}
}

View file

@ -1,66 +0,0 @@
package com.lagradost.cloudstream3.plugins
import android.app.Activity
import android.content.Context
import android.util.Log
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.extractorApis
/**
* The base class that CloudStream extensions extend in NuvioTV.
* Kept standalone (not extending BasePlugin) because BasePlugin's registration
* methods are final and can't be overridden. Extensions compiled against the
* real CloudStream app reference this class directly.
*/
open class Plugin {
private val _registeredMainAPIs = mutableListOf<MainAPI>()
private val _registeredExtractorAPIs = mutableListOf<ExtractorApi>()
val registeredMainAPIs: List<MainAPI> get() = _registeredMainAPIs
val registeredExtractorAPIs: List<ExtractorApi> get() = _registeredExtractorAPIs
/** Extensions can set this to provide a settings UI callback. No-op in NuvioTV. */
var openSettings: ((Context) -> Unit)? = null
/** Full file path to the plugin (matches BasePlugin's property). */
var filename: String? = null
/**
* No-arg load matching BasePlugin's pattern. Extensions compiled against
* CloudStream may override this instead of load(Activity?).
*/
open fun load() {}
/**
* Called when the plugin is loaded. Override to register APIs.
* The [activity] parameter may be null when loaded outside an Activity context.
* Delegates to no-arg load() so BasePlugin-style extensions get invoked.
*/
@Suppress("UNUSED_PARAMETER")
open fun load(activity: Activity?) {
load()
}
fun registerMainAPI(element: MainAPI) {
Log.d("CS3Plugin", "registerMainAPI called: ${element.name} (${element.javaClass.name})")
_registeredMainAPIs.add(element)
// Also register globally for extensions that access APIHolder directly
element.sourcePlugin = this.filename
try {
com.lagradost.cloudstream3.APIHolder.addPluginMapping(element)
} catch (_: Exception) {}
}
fun registerExtractorAPI(element: ExtractorApi) {
Log.d("CS3Plugin", "registerExtractorAPI called: ${element.name} (${element.javaClass.name})")
_registeredExtractorAPIs.add(element)
element.sourcePlugin = this.filename
extractorApis.add(element)
}
// Some extensions call these overloads
open fun load(context: Context) {
load(context as? Activity)
}
}

View file

@ -1,16 +0,0 @@
@file:Suppress("unused")
package com.lagradost.cloudstream3.plugins
/** Stub for CloudStream PluginManager referenced by some extensions. */
object PluginManager {
data class PluginData(
val name: String = "",
val url: String = "",
val internalName: String = "",
val version: Int = 0
)
fun getPluginsOnline(): Array<PluginData> = emptyArray()
fun unloadPlugin(filePath: String) {}
}

View file

@ -1,131 +0,0 @@
@file:Suppress("unused")
package com.lagradost.cloudstream3.utils
import android.content.Context
import android.content.SharedPreferences
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.lagradost.cloudstream3.mvvm.logError
import androidx.core.content.edit
const val PREFERENCES_NAME = "cs3_plugin_preferences"
/**
* DataStore stub matching real CloudStream3's DataStore object.
* Provides SharedPreferences-based storage for extension settings.
*/
object DataStore {
val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
private fun getPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
}
fun Context.getSharedPrefs(): SharedPreferences {
return getPreferences(this)
}
fun Context.getDefaultSharedPrefs(): SharedPreferences {
return getPreferences(this)
}
fun getFolderName(folder: String, path: String): String {
return "${folder}/${path}"
}
fun Context.getKeys(folder: String): List<String> {
val fixedFolder = folder.trimEnd('/') + "/"
return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) }
}
fun Context.containsKey(path: String): Boolean {
return getSharedPrefs().contains(path)
}
fun Context.containsKey(folder: String, path: String): Boolean {
return containsKey(getFolderName(folder, path))
}
fun Context.removeKey(path: String) {
try {
val prefs = getSharedPrefs()
if (prefs.contains(path)) {
prefs.edit { remove(path) }
}
} catch (e: Exception) {
logError(e)
}
}
fun Context.removeKey(folder: String, path: String) {
removeKey(getFolderName(folder, path))
}
fun Context.removeKeys(folder: String): Int {
val keys = getKeys("$folder/")
try {
getSharedPrefs().edit {
keys.forEach { value -> remove(value) }
}
return keys.size
} catch (e: Exception) {
logError(e)
return 0
}
}
fun <T> Context.setKey(path: String, value: T) {
try {
getSharedPrefs().edit {
putString(path, mapper.writeValueAsString(value))
}
} catch (e: Exception) {
logError(e)
}
}
fun <T> Context.setKey(folder: String, path: String, value: T) {
setKey(getFolderName(folder, path), value)
}
inline fun <reified T : Any> String.toKotlinObject(): T {
return mapper.readValue(this, T::class.java)
}
fun <T> String.toKotlinObject(valueType: Class<T>): T {
return mapper.readValue(this, valueType)
}
fun <T> Context.getKey(path: String, valueType: Class<T>): T? {
try {
val json: String = getSharedPrefs().getString(path, null) ?: return null
return json.toKotlinObject(valueType)
} catch (e: Exception) {
return null
}
}
inline fun <reified T : Any> Context.getKey(path: String, defVal: T?): T? {
try {
val json: String = getSharedPrefs().getString(path, null) ?: return defVal
return json.toKotlinObject()
} catch (e: Exception) {
return null
}
}
inline fun <reified T : Any> Context.getKey(path: String): T? {
return getKey(path, null)
}
inline fun <reified T : Any> Context.getKey(folder: String, path: String): T? {
return getKey(getFolderName(folder, path), null)
}
inline fun <reified T : Any> Context.getKey(folder: String, path: String, defVal: T?): T? {
return getKey(getFolderName(folder, path), defVal) ?: defVal
}
}

View file

@ -3,7 +3,6 @@ package com.nuvio.tv
import android.os.Bundle
import android.content.Context
import android.content.res.Configuration
import androidx.core.os.ConfigurationCompat
import android.util.Log
import androidx.compose.ui.platform.LocalView
import androidx.metrics.performance.JankStats
@ -28,7 +27,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -56,7 +54,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -85,9 +82,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -101,7 +96,6 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text
import androidx.tv.material3.rememberDrawerState
import com.nuvio.tv.core.profile.ProfileManager
@ -110,13 +104,10 @@ import com.nuvio.tv.data.local.AppOnboardingDataStore
import com.nuvio.tv.data.local.LayoutPreferenceDataStore
import com.nuvio.tv.data.local.ThemeDataStore
import com.nuvio.tv.data.repository.TraktProgressService
import com.nuvio.tv.domain.model.AppFont
import com.nuvio.tv.domain.model.AppTheme
import com.nuvio.tv.domain.model.AuthState
import com.nuvio.tv.core.sync.ProfileSettingsSyncService
import com.nuvio.tv.core.sync.ProfileSyncService
import com.nuvio.tv.core.sync.StartupSyncService
import com.nuvio.tv.data.remote.supabase.AvatarRepository
import com.nuvio.tv.ui.navigation.NuvioNavHost
import com.nuvio.tv.ui.navigation.Screen
import com.nuvio.tv.ui.components.NuvioScrollDefaults
@ -141,9 +132,6 @@ import coil.request.ImageRequest
import androidx.compose.ui.res.stringResource
import com.nuvio.tv.R
val LocalSidebarExpanded = compositionLocalOf { false }
val LocalContentFocusRequester = compositionLocalOf { FocusRequester.Default }
data class DrawerItem(
val route: String,
val label: String,
@ -153,7 +141,6 @@ data class DrawerItem(
private data class MainUiPrefs(
val theme: AppTheme = AppTheme.WHITE,
val font: AppFont = AppFont.INTER,
val hasChosenLayout: Boolean? = null,
val sidebarCollapsed: Boolean = false,
val modernSidebarEnabled: Boolean = false,
@ -175,9 +162,6 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var startupSyncService: StartupSyncService
@Inject
lateinit var profileSettingsSyncService: ProfileSettingsSyncService
@Inject
lateinit var profileSyncService: ProfileSyncService
@ -190,9 +174,6 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var appOnboardingDataStore: AppOnboardingDataStore
@Inject
lateinit var avatarRepository: AvatarRepository
private lateinit var jankStats: JankStats
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@ -206,9 +187,6 @@ class MainActivity : ComponentActivity() {
config.setLocale(locale)
super.attachBaseContext(newBase.createConfigurationContext(config))
} else {
val systemLocale = ConfigurationCompat.getLocales(newBase.resources.configuration)[0]
?: Locale.getDefault(Locale.Category.DISPLAY)
Locale.setDefault(systemLocale)
super.attachBaseContext(newBase)
}
}
@ -217,12 +195,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
window?.setBackgroundDrawable(null)
// Store Activity reference for CloudStream extensions that need it in plugin.load()
com.lagradost.cloudstream3.AcraApplication.setActivity(this)
setContent {
var hasSelectedProfileThisSession by rememberSaveable { mutableStateOf(false) }
var hasSelectedProfileThisSession by remember { mutableStateOf(false) }
var onboardingCompletedThisSession by remember { mutableStateOf(false) }
var onboardingProfileSyncInProgress by remember { mutableStateOf(false) }
val hasSeenAuthQrOnFirstLaunch by appOnboardingDataStore
@ -243,63 +217,33 @@ class MainActivity : ComponentActivity() {
val activeProfile = remember(activeProfileId, profiles) {
profiles.firstOrNull { it.id == activeProfileId }
}
var profilePinStates by remember { mutableStateOf<Map<Int, Boolean>>(emptyMap()) }
LaunchedEffect(authState, profiles) {
if (authState is AuthState.FullAccount) {
profileSyncService.pullProfileLockStates()
.onSuccess { profilePinStates = it }
.onFailure { profilePinStates = emptyMap() }
} else {
profilePinStates = emptyMap()
}
}
val activeProfileHasPin = remember(activeProfileId, profilePinStates) {
profilePinStates[activeProfileId] == true
}
var avatarCatalog by remember { mutableStateOf(emptyList<com.nuvio.tv.data.remote.supabase.AvatarCatalogItem>()) }
LaunchedEffect(Unit) {
avatarCatalog = runCatching { avatarRepository.getAvatarCatalog() }
.getOrDefault(emptyList())
}
val activeProfileAvatarImageUrl = remember(activeProfile, avatarCatalog) {
activeProfile?.avatarId?.let { avatarRepository.getAvatarImageUrl(it, avatarCatalog) }
}
val mainUiPrefsFlow = remember(themeDataStore, layoutPreferenceDataStore) {
combine(
themeDataStore.selectedTheme,
themeDataStore.selectedFont,
layoutPreferenceDataStore.hasChosenLayout,
layoutPreferenceDataStore.sidebarCollapsedByDefault,
layoutPreferenceDataStore.modernSidebarEnabled,
) { theme, font, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled ->
layoutPreferenceDataStore.modernSidebarBlurEnabled
) { theme, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled, modernSidebarBlurPref ->
MainUiPrefs(
theme = theme,
font = font,
hasChosenLayout = hasChosenLayout,
sidebarCollapsed = sidebarCollapsed,
modernSidebarEnabled = modernSidebarEnabled,
modernSidebarBlurPref = modernSidebarBlurPref
)
}.combine(layoutPreferenceDataStore.modernSidebarBlurEnabled) { prefs, modernSidebarBlurPref ->
prefs.copy(modernSidebarBlurPref = modernSidebarBlurPref)
}
}
val mainUiPrefs by mainUiPrefsFlow.collectAsState(initial = MainUiPrefs(hasChosenLayout = null))
NuvioTheme(appTheme = mainUiPrefs.theme, appFont = mainUiPrefs.font) {
NuvioTheme(appTheme = mainUiPrefs.theme) {
CompositionLocalProvider(
LocalBringIntoViewSpec provides NuvioScrollDefaults.smoothScrollSpec
) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RectangleShape,
colors = SurfaceDefaults.colors(
containerColor = NuvioColors.Background
)
shape = RectangleShape
) {
if (hasSeenAuthQrOnFirstLaunch == null) {
Box(
@ -357,7 +301,7 @@ class MainActivity : ComponentActivity() {
}
val shouldShowProfileSelection =
!hasSelectedProfileThisSession && (profiles.size > 1 || activeProfileHasPin)
!hasSelectedProfileThisSession && profiles.size > 1
if (shouldShowProfileSelection) {
ProfileSelectionScreen(
@ -471,8 +415,6 @@ class MainActivity : ComponentActivity() {
hideBuiltInHeaders = hideBuiltInHeadersForFloatingPill,
activeProfileName = activeProfile?.name ?: "",
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = profiles.size > 1,
onSwitchProfile = { hasSelectedProfileThisSession = false },
onExitApp = {
finishAffinity()
@ -491,8 +433,6 @@ class MainActivity : ComponentActivity() {
hideBuiltInHeaders = false,
activeProfileName = activeProfile?.name ?: "",
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = profiles.size > 1,
onSwitchProfile = { hasSelectedProfileThisSession = false },
onExitApp = {
finishAffinity()
@ -527,10 +467,6 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
if (::jankStats.isInitialized) jankStats.isTrackingEnabled = true
startupSyncService.requestSyncNow(includeProfileSettings = false)
lifecycleScope.launch {
traktProgressService.refreshNow()
}
}
override fun onPause() {
@ -540,12 +476,10 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
profileSettingsSyncService.requestForegroundPull()
}
override fun onDestroy() {
super.onDestroy()
com.lagradost.cloudstream3.AcraApplication.setActivity(null)
startupSyncService.requestSyncNow()
lifecycleScope.launch {
traktProgressService.refreshNow()
}
}
}
@ -562,8 +496,6 @@ private fun LegacySidebarScaffold(
hideBuiltInHeaders: Boolean,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit,
onExitApp: () -> Unit
) {
@ -578,11 +510,9 @@ private fun LegacySidebarScaffold(
}
val closedDrawerWidth = if (sidebarCollapsed) 0.dp else 72.dp
val openDrawerWidth = 196.dp
val openDrawerWidth = 216.dp
val focusManager = LocalFocusManager.current
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
val contentFocusRequester = remember { FocusRequester() }
var pendingContentFocusTransfer by remember { mutableStateOf(false) }
var pendingSidebarFocusRequest by remember { mutableStateOf(false) }
@ -600,7 +530,7 @@ private fun LegacySidebarScaffold(
return@LaunchedEffect
}
repeat(2) { withFrameNanos { } }
runCatching { contentFocusRequester.requestFocus() }
focusManager.moveFocus(FocusDirection.Right)
pendingContentFocusTransfer = false
}
@ -634,8 +564,7 @@ private fun LegacySidebarScaffold(
.padding(12.dp)
.selectableGroup()
.onPreviewKeyEvent { keyEvent ->
val closeKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
if (keyEvent.key == closeKey && keyEvent.type == KeyEventType.KeyDown) {
if (keyEvent.key == Key.DirectionRight && keyEvent.type == KeyEventType.KeyDown) {
drawerState.setValue(DrawerValue.Closed)
pendingContentFocusTransfer = false
true
@ -645,69 +574,21 @@ private fun LegacySidebarScaffold(
}
) {
val isExpanded = drawerValue == DrawerValue.Open
val itemWidth = if (isExpanded) 156.dp else 48.dp
if (isExpanded) {
Spacer(modifier = Modifier.height(30.dp))
if (showProfileSelector && activeProfileName.isNotEmpty()) {
var isProfileFocused by remember { mutableStateOf(false) }
val profileItemShape = RoundedCornerShape(32.dp)
val profileLeadingInset = 18.dp
val profileAvatarSize = 34.dp
val profileLabelStart = 60.dp
val profileGapAfterAvatar =
(profileLabelStart - profileLeadingInset - profileAvatarSize).coerceAtLeast(0.dp)
val profileBgColor by animateColorAsState(
targetValue = if (isProfileFocused) NuvioColors.FocusBackground else Color.Transparent,
label = "legacyProfileItemBg"
)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.width(itemWidth)
.height(52.dp)
.background(color = profileBgColor, shape = profileItemShape)
.onFocusChanged { isProfileFocused = it.isFocused }
.clickable {
onSwitchProfile()
drawerState.setValue(DrawerValue.Closed)
},
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(profileLeadingInset))
ProfileAvatarCircle(
name = activeProfileName,
colorHex = activeProfileColorHex,
size = profileAvatarSize,
avatarImageUrl = activeProfileAvatarImageUrl
)
Spacer(modifier = Modifier.width(profileGapAfterAvatar))
Text(
text = activeProfileName,
color = if (isProfileFocused) NuvioColors.TextPrimary else NuvioColors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
fontWeight = FontWeight.SemiBold
)
}
}
} else {
Image(
painter = painterResource(id = R.drawable.app_logo_wordmark),
contentDescription = "NuvioTV",
modifier = Modifier
.fillMaxWidth()
.height(42.dp)
)
}
Image(
painter = painterResource(id = R.drawable.app_logo_wordmark),
contentDescription = "NuvioTV",
modifier = Modifier
.fillMaxWidth()
.height(42.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.weight(1f))
val itemWidth = if (isExpanded) 176.dp else 48.dp
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
@ -737,28 +618,64 @@ private fun LegacySidebarScaffold(
}
Spacer(modifier = Modifier.weight(1f))
if (isExpanded && activeProfileName.isNotEmpty()) {
var isProfileFocused by remember { mutableStateOf(false) }
val profileItemShape = RoundedCornerShape(32.dp)
val profileBgColor by animateColorAsState(
targetValue = if (isProfileFocused) NuvioColors.FocusBackground else Color.Transparent,
label = "legacyProfileItemBg"
)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.width(itemWidth)
.height(52.dp)
.clip(profileItemShape)
.background(color = profileBgColor, shape = profileItemShape)
.onFocusChanged { isProfileFocused = it.isFocused }
.clickable {
onSwitchProfile()
drawerState.setValue(DrawerValue.Closed)
},
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(10.dp))
ProfileAvatarCircle(
name = activeProfileName,
colorHex = activeProfileColorHex,
size = 34.dp
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = activeProfileName,
color = if (isProfileFocused) NuvioColors.TextPrimary else NuvioColors.TextSecondary,
maxLines = 1,
textAlign = TextAlign.Start
)
}
}
}
}
}
}
) {
val contentStartPadding by animateDpAsState(
targetValue = if (showSidebar) closedDrawerWidth else 0.dp,
animationSpec = tween(350),
label = "contentStartPadding"
)
val contentStartPadding = if (showSidebar) closedDrawerWidth else 0.dp
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = contentStartPadding)
.onKeyEvent { keyEvent ->
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
if (
showSidebar &&
drawerState.currentValue == DrawerValue.Closed &&
keyEvent.type == KeyEventType.KeyDown &&
keyEvent.key == openKey
keyEvent.key == Key.DirectionLeft
) {
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
if (focusManager.moveFocus(FocusDirection.Left)) {
true
} else {
pendingSidebarFocusRequest = true
@ -770,16 +687,11 @@ private fun LegacySidebarScaffold(
}
}
) {
CompositionLocalProvider(
LocalSidebarExpanded provides (drawerState.currentValue == DrawerValue.Open),
LocalContentFocusRequester provides contentFocusRequester
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
}
}
@ -799,7 +711,7 @@ private fun LegacySidebarButton(
val backgroundColor by animateColorAsState(
targetValue = when {
isFocused -> NuvioColors.FocusBackground
expanded && selected -> NuvioColors.Secondary
selected -> NuvioColors.BackgroundCard
else -> Color.Transparent
},
label = "legacySidebarItemBackground"
@ -807,26 +719,17 @@ private fun LegacySidebarButton(
val contentColor by animateColorAsState(
targetValue = when {
isFocused -> NuvioColors.TextPrimary
expanded && selected -> NuvioColors.OnSecondary
selected -> NuvioColors.TextPrimary
else -> NuvioColors.TextSecondary
},
label = "legacySidebarItemContent"
)
val iconTint by animateColorAsState(
targetValue = when {
isFocused -> NuvioColors.TextPrimary
expanded && selected -> NuvioColors.OnSecondary
selected -> NuvioColors.Secondary
!expanded -> NuvioColors.TextTertiary
else -> NuvioColors.TextSecondary
},
label = "legacySidebarItemIconTint"
)
Box(
modifier = modifier
.height(52.dp)
.focusProperties { canFocus = expanded }
.clip(itemShape)
.background(color = backgroundColor, shape = itemShape)
.onFocusChanged { isFocused = it.isFocused }
.clickable(onClick = onClick),
@ -834,7 +737,7 @@ private fun LegacySidebarButton(
DrawerItemIcon(
iconRes = iconRes,
icon = icon,
tint = iconTint,
tint = contentColor,
modifier = if (expanded) {
Modifier
.size(22.dp)
@ -876,8 +779,6 @@ private fun ModernSidebarScaffold(
hideBuiltInHeaders: Boolean,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit,
onExitApp: () -> Unit
) {
@ -886,8 +787,6 @@ private fun ModernSidebarScaffold(
val openSidebarWidth = 262.dp
val focusManager = LocalFocusManager.current
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
val contentFocusRequester = remember { FocusRequester() }
val drawerItemFocusRequesters = remember(drawerItems) {
drawerItems.associate { item -> item.route to FocusRequester() }
}
@ -901,8 +800,6 @@ private fun ModernSidebarScaffold(
val keepFloatingPillExpanded = selectedDrawerRoute == Screen.Settings.route
val keepSidebarFocusDuringCollapse =
isSidebarExpanded || sidebarCollapsePending || pendingContentFocusTransfer
val hasSidebarProfileItem = showProfileSelector && activeProfileName.isNotEmpty()
val sidebarTopBoundaryIndex = if (hasSidebarProfileItem) drawerItems.size else 0
LaunchedEffect(showSidebar) {
if (!showSidebar) {
@ -1057,7 +954,7 @@ private fun ModernSidebarScaffold(
return@LaunchedEffect
}
repeat(2) { withFrameNanos { } }
runCatching { contentFocusRequester.requestFocus() }
focusManager.moveFocus(FocusDirection.Right)
pendingContentFocusTransfer = false
}
@ -1082,6 +979,13 @@ private fun ModernSidebarScaffold(
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (shouldApplySidebarHaze) {
Modifier.haze(sidebarHazeState)
} else {
Modifier
}
)
.onPreviewKeyEvent { keyEvent ->
if (
isSidebarExpanded &&
@ -1104,9 +1008,8 @@ private fun ModernSidebarScaffold(
else -> Unit
}
}
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
if (keyEvent.key == openKey) {
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
if (keyEvent.key == Key.DirectionLeft) {
if (focusManager.moveFocus(FocusDirection.Left)) {
true
} else {
isSidebarExpanded = true
@ -1122,16 +1025,11 @@ private fun ModernSidebarScaffold(
}
}
) {
CompositionLocalProvider(
LocalSidebarExpanded provides isSidebarExpanded,
LocalContentFocusRequester provides contentFocusRequester
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) {
@ -1162,22 +1060,17 @@ private fun ModernSidebarScaffold(
}
when (keyEvent.key) {
Key.DirectionUp -> {
focusedDrawerIndex == sidebarTopBoundaryIndex
focusedDrawerIndex == 0
}
Key.DirectionDown -> {
focusedDrawerIndex == drawerItems.lastIndex
focusedDrawerIndex > drawerItems.lastIndex
}
Key.DirectionRight, Key.DirectionLeft -> {
val collapseKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
if (keyEvent.key == collapseKey) {
pendingContentFocusTransfer = false
sidebarCollapsePending = true
true
} else {
false
}
Key.DirectionRight -> {
pendingContentFocusTransfer = false
sidebarCollapsePending = true
true
}
else -> false
@ -1212,8 +1105,6 @@ private fun ModernSidebarScaffold(
},
activeProfileName = activeProfileName,
activeProfileColorHex = activeProfileColorHex,
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = showProfileSelector,
onSwitchProfile = onSwitchProfile
)
}
@ -1234,7 +1125,7 @@ private fun ModernSidebarScaffold(
.align(Alignment.TopStart)
.offset {
IntOffset(
14.dp.roundToPx(),
(40.dp + sidebarSlideX + sidebarDeflateOffsetX).roundToPx(),
(16.dp + sidebarDeflateOffsetY).roundToPx()
)
}
@ -1308,6 +1199,7 @@ private fun CollapsedSidebarPill(
.graphicsLayer {
shape = pillShape
clip = true
compositingStrategy = CompositingStrategy.Offscreen
}
.clip(pillShape)
.background(brush = pillBackgroundBrush, shape = pillShape)

View file

@ -41,10 +41,10 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
@ -57,10 +57,6 @@ import coil.compose.rememberAsyncImagePainter
import coil.decode.SvgDecoder
import coil.request.ImageRequest
private val SidebarLeadingVisualSize = 34.dp
private val SidebarContentGap = 14.dp
private val SidebarProfileContentGap = 18.dp
@Composable
internal fun ModernSidebarBlurPanel(
drawerItems: List<DrawerItem>,
@ -79,8 +75,6 @@ internal fun ModernSidebarBlurPanel(
onDrawerItemClick: (String) -> Unit,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit
) {
val delayedBlurProgress =
@ -135,43 +129,19 @@ internal fun ModernSidebarBlurPanel(
.border(width = 1.dp, color = panelBorderColor, shape = panelShape)
.padding(horizontal = 12.dp, vertical = 14.dp)
) {
if (showProfileSelector && activeProfileName.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.offset(y = 12.dp),
contentAlignment = Alignment.Center
) {
SidebarProfileItem(
profileName = activeProfileName,
profileColorHex = activeProfileColorHex,
profileAvatarImageUrl = activeProfileAvatarImageUrl,
focusEnabled = keepSidebarFocusDuringCollapse,
labelAlpha = sidebarLabelAlpha,
onFocusChanged = { focused ->
if (focused) onDrawerItemFocused(drawerItems.size)
},
onClick = onSwitchProfile,
modifier = Modifier.fillMaxWidth(0.92f)
)
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.offset(y = 12.dp),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.app_logo_wordmark),
contentDescription = "NuvioTV",
modifier = Modifier
.fillMaxWidth(0.72f)
.height(36.dp),
alpha = sidebarLabelAlpha
)
}
}
val headerLogoRes = if (isSidebarExpanded) R.drawable.app_logo_wordmark else R.drawable.app_logo_mark
val headerLogoHeight = if (isSidebarExpanded) 42.dp else 34.dp
val headerLogoContentDescription = if (isSidebarExpanded) "NuvioTV" else "Nuvio"
Image(
painter = painterResource(id = headerLogoRes),
contentDescription = headerLogoContentDescription,
modifier = Modifier
.fillMaxWidth()
.height(headerLogoHeight)
.offset(y = 12.dp),
contentScale = ContentScale.Fit
)
Spacer(modifier = Modifier.height(16.dp))
@ -209,6 +179,25 @@ internal fun ModernSidebarBlurPanel(
}
}
if (activeProfileName.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
SidebarProfileItem(
profileName = activeProfileName,
profileColorHex = activeProfileColorHex,
focusEnabled = keepSidebarFocusDuringCollapse,
labelAlpha = sidebarLabelAlpha,
onFocusChanged = { focused ->
if (focused) onDrawerItemFocused(drawerItems.size)
},
onClick = onSwitchProfile,
modifier = Modifier.fillMaxWidth(0.92f)
)
}
}
}
}
@ -244,6 +233,9 @@ private fun SidebarNavigationItem(
val contentColor = if (selected) Color(0xFF10151F) else Color.White
val iconCircleColor = if (selected) Color(0xFFE7E2EF) else Color(0xFF6A6A74)
val iconContainerSize = 34.dp
val contentGap = 14.dp
Row(
modifier = modifier
.clip(shape)
@ -267,7 +259,7 @@ private fun SidebarNavigationItem(
) {
Box(
modifier = Modifier
.size(SidebarLeadingVisualSize)
.size(iconContainerSize)
.clip(CircleShape)
.background(iconCircleColor)
.padding(6.dp)
@ -293,7 +285,7 @@ private fun SidebarNavigationItem(
)
}
}
Spacer(modifier = Modifier.width(SidebarContentGap))
Spacer(modifier = Modifier.width(contentGap))
Text(
text = label,
@ -305,6 +297,7 @@ private fun SidebarNavigationItem(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(iconContainerSize + contentGap))
}
}
@ -312,7 +305,6 @@ private fun SidebarNavigationItem(
private fun SidebarProfileItem(
profileName: String,
profileColorHex: String,
profileAvatarImageUrl: String?,
focusEnabled: Boolean,
labelAlpha: Float,
onFocusChanged: (Boolean) -> Unit,
@ -331,6 +323,9 @@ private fun SidebarProfileItem(
animationSpec = tween(durationMillis = 180),
label = "profileItemBorder"
)
val contentGap = 14.dp
val iconContainerSize = 34.dp
Row(
modifier = modifier
.clip(shape)
@ -352,29 +347,19 @@ private fun SidebarProfileItem(
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(SidebarLeadingVisualSize),
contentAlignment = Alignment.Center
) {
ProfileAvatarCircle(
name = profileName,
colorHex = profileColorHex,
size = SidebarLeadingVisualSize,
avatarImageUrl = profileAvatarImageUrl
)
}
Spacer(modifier = Modifier.width(SidebarProfileContentGap))
ProfileAvatarCircle(
name = profileName,
colorHex = profileColorHex,
size = iconContainerSize
)
Spacer(modifier = Modifier.width(contentGap))
Text(
text = profileName,
color = Color.White,
modifier = Modifier
.weight(1f)
.graphicsLayer { alpha = labelAlpha },
style = androidx.tv.material3.MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
maxLines = 1
)
}
}

View file

@ -1,29 +1,13 @@
package com.nuvio.tv
import android.app.Application
import android.os.Build
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import android.util.Log
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.app
import com.lagradost.nicehttp.ignoreAllSSLErrors
import com.nuvio.tv.core.sync.StartupSyncService
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import okhttp3.Cache
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import org.conscrypt.Conscrypt
import java.io.File
import java.security.Security
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@HiltAndroidApp
@ -31,79 +15,15 @@ class NuvioApplication : Application(), ImageLoaderFactory {
@Inject lateinit var startupSyncService: StartupSyncService
companion object {
/**
* Shared cookie jar for CloudStream extension HTTP requests.
* Accessible so the player's OkHttpClient can share cookies
* obtained during scraping (e.g., session tokens needed for playback).
*/
val extensionCookieJar: CookieJar = object : CookieJar {
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return store[url.host]?.filter { cookie ->
cookie.expiresAt > System.currentTimeMillis()
} ?: emptyList()
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val hostCookies = store.getOrPut(url.host) { mutableListOf() }
cookies.forEach { newCookie ->
hostCookies.removeAll { it.name == newCookie.name }
hostCookies.add(newCookie)
}
}
}
}
override fun onCreate() {
super.onCreate()
// Install Conscrypt as the primary TLS provider.
// This gives OkHttp a browser-compatible TLS fingerprint (JA3/JA4),
// which is critical for bypassing Cloudflare's bot detection.
// Without it, Cloudflare blocks OkHttp even with valid cf_clearance cookies.
try {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
} catch (e: Exception) {
Log.w("NuvioApplication", "Failed to install Conscrypt: ${e.message}")
}
// Initialize the CloudStream NiceHTTP singleton's OkHttpClient.
// Matches CloudStream's RequestsHelper.initClient() setup.
// Wrapped in try catch because java.lang.BootstrapMethodError
// doesn't exist on API < 26 (e.g. Fire TV 4K Gen 1 running Android 7.1.2)
try {
app.baseClient = OkHttpClient.Builder()
.cookieJar(extensionCookieJar)
.followRedirects(true)
.followSslRedirects(true)
.ignoreAllSSLErrors()
.cache(Cache(
directory = File(cacheDir, "http_cache"),
maxSize = 50L * 1024L * 1024L
))
.build()
} catch (e: Throwable) {
Log.w("NuvioApplication", "Failed to initialize NiceHttp client (API ${Build.VERSION.SDK_INT}): ${e.message}")
}
// Set AcraApplication context early so CS3 stubs can access it
AcraApplication.context = this
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.33)
.maxSizePercent(0.25)
.build()
}
.diskCache {

View file

@ -2,7 +2,6 @@ package com.nuvio.tv.core.auth
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.local.AuthSessionNoticeDataStore
import com.nuvio.tv.data.remote.supabase.TvLoginExchangeResult
import com.nuvio.tv.data.remote.supabase.TvLoginPollResult
import com.nuvio.tv.data.remote.supabase.TvLoginStartResult
@ -35,8 +34,7 @@ private const val TAG = "AuthManager"
class AuthManager @Inject constructor(
private val auth: Auth,
private val postgrest: Postgrest,
private val httpClient: OkHttpClient,
private val authSessionNoticeDataStore: AuthSessionNoticeDataStore
private val httpClient: OkHttpClient
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true }
@ -64,10 +62,8 @@ class AuthManager @Inject constructor(
}
if (user.email.isNullOrBlank()) {
_authState.value = AuthState.SignedOut
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
} else {
_authState.value = AuthState.FullAccount(userId = user.id, email = user.email!!)
authSessionNoticeDataStore.markNuvioAuthenticated()
}
}
}
@ -82,14 +78,12 @@ class AuthManager @Inject constructor(
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
_authState.value = AuthState.SignedOut
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
}
}
} else {
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
_authState.value = AuthState.SignedOut
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
}
}
is SessionStatus.Initializing -> {
@ -206,12 +200,7 @@ class AuthManager @Inject constructor(
}
}
suspend fun signOut(explicit: Boolean = true) {
if (explicit) {
authSessionNoticeDataStore.markNuvioExplicitLogout()
} else {
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
}
suspend fun signOut() {
try {
auth.signOut()
} catch (e: Exception) {

View file

@ -5,9 +5,7 @@ import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.AddonApi
import com.nuvio.tv.data.remote.api.AniSkipApi
import com.nuvio.tv.data.remote.api.AnimeSkipApi
import com.nuvio.tv.data.remote.api.ArmApi
import com.nuvio.tv.data.remote.api.DonationsApi
import com.nuvio.tv.data.remote.api.GitHubReleaseApi
import com.nuvio.tv.data.remote.api.TraktApi
import com.nuvio.tv.data.remote.api.TrailerApi
@ -29,17 +27,11 @@ import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import com.nuvio.tv.core.network.IPv4FirstDns
import java.io.File
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Named
import javax.inject.Singleton
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
private object TraktHttpTrace {
private val requestCounter = AtomicLong(0L)
@ -58,39 +50,15 @@ object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient {
val trustAllManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf<TrustManager>(trustAllManager), SecureRandom())
}
return OkHttpClient.Builder()
.dns(IPv4FirstDns())
.sslSocketFactory(sslContext.socketFactory, trustAllManager)
.hostnameVerifier { _, _ -> true }
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
// Prevent OkHttp from caching error responses (4xx/5xx).
.addNetworkInterceptor { chain ->
val response = chain.proceed(chain.request())
if (!response.isSuccessful) {
response.newBuilder()
.header("Cache-Control", "no-store")
.build()
} else {
response
}
}
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
else HttpLoggingInterceptor.Level.NONE
})
.build()
}
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient = OkHttpClient.Builder()
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
else HttpLoggingInterceptor.Level.NONE
})
.build()
@Provides
@Singleton
@ -262,21 +230,6 @@ object NetworkModule {
fun provideArmApi(@Named("arm") retrofit: Retrofit): ArmApi =
retrofit.create(ArmApi::class.java)
@Provides
@Singleton
@Named("animeSkipGql")
fun provideAnimeSkipGqlRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit =
Retrofit.Builder()
.baseUrl("https://api.anime-skip.com/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
@Provides
@Singleton
fun provideAnimeSkipApi(@Named("animeSkipGql") retrofit: Retrofit): AnimeSkipApi =
retrofit.create(AnimeSkipApi::class.java)
// --- GitHub Releases API (in-app updates) ---
@Provides
@ -294,26 +247,6 @@ object NetworkModule {
fun provideGitHubReleaseApi(@Named("github") retrofit: Retrofit): GitHubReleaseApi =
retrofit.create(GitHubReleaseApi::class.java)
@Provides
@Singleton
@Named("donations")
fun provideDonationsRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
val baseUrl = BuildConfig.DONATIONS_BASE_URL
.takeIf { it.isNotBlank() }
?: error("DONATIONS_BASE_URL is missing. Set it in local.properties or local.dev.properties.")
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideDonationsApi(@Named("donations") retrofit: Retrofit): DonationsApi =
retrofit.create(DonationsApi::class.java)
// --- Trailer API ---
@Provides

View file

@ -1,43 +0,0 @@
package com.nuvio.tv.core.di
import android.content.Context
import com.nuvio.tv.core.torrent.TorrServerApi
import com.nuvio.tv.core.torrent.TorrServerBinary
import com.nuvio.tv.core.torrent.TorrentService
import com.nuvio.tv.core.torrent.TorrentSettings
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object TorrentModule {
@Provides
@Singleton
fun provideTorrentSettings(
@ApplicationContext context: Context
): TorrentSettings = TorrentSettings(context)
@Provides
@Singleton
fun provideTorrServerBinary(
@ApplicationContext context: Context
): TorrServerBinary = TorrServerBinary(context)
@Provides
@Singleton
fun provideTorrServerApi(
binary: TorrServerBinary
): TorrServerApi = TorrServerApi(binary)
@Provides
@Singleton
fun provideTorrentService(
binary: TorrServerBinary,
api: TorrServerApi
): TorrentService = TorrentService(binary, api)
}

View file

@ -1,17 +0,0 @@
package com.nuvio.tv.core.network
import okhttp3.Dns
import java.net.Inet4Address
import java.net.InetAddress
/**
* Custom DNS that reorders resolved addresses to place IPv4 (Inet4Address)
* before IPv6 (Inet6Address). This avoids 60s timeout delays on networks
* with broken IPv6 routing (issue #651).
*/
class IPv4FirstDns(private val delegate: Dns = Dns.SYSTEM) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
val addresses = delegate.lookup(hostname)
return addresses.sortedBy { if (it is Inet4Address) 0 else 1 }
}
}

View file

@ -4,7 +4,6 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import com.nuvio.tv.R
object ExternalPlayerLauncher {
@ -38,7 +37,7 @@ object ExternalPlayerLauncher {
} catch (e: Exception) {
Toast.makeText(
context,
context.getString(R.string.player_no_external_player),
"No external player found",
Toast.LENGTH_LONG
).show()
false

View file

@ -2,23 +2,18 @@ package com.nuvio.tv.core.player
import android.app.Activity
import android.content.Context
import android.hardware.display.DisplayManager
import android.media.MediaExtractor
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Display
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfo
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfoBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Locale
/**
* Auto frame rate matching utility.
@ -27,29 +22,28 @@ import java.util.Locale
object FrameRateUtils {
private const val TAG = "FrameRateUtils"
private const val SWITCH_TIMEOUT_MS = 4000L
private const val SWITCH_TIMEOUT_MS = 5000L
private const val REFRESH_MATCH_MIN_TOLERANCE_HZ = 0.08f
private const val NTSC_FILM_FPS = 24000f / 1001f
private const val CINEMA_24_FPS = 24f
private const val MIN_VALID_VIDEO_FPS = 10f
private const val MAX_VALID_VIDEO_FPS = 120f
private val NEXTLIB_HTTP_SCHEMES = setOf("http", "https")
private val LIVE_STREAM_EXTENSIONS = listOf(".m3u8", ".mpd", ".ism/manifest")
private const val MKV_EXTENSION = ".mkv"
private const val SWITCH_POLL_INTERVAL_MS = 60L
private const val SWITCH_REQUIRED_STABLE_POLLS = 2
private var displayManager: DisplayManager? = null
private var displayListener: DisplayManager.DisplayListener? = null
private var timeoutHandler: Handler? = null
private var timeoutRunnable: Runnable? = null
data class DisplayModeSwitchResult(
val appliedMode: Display.Mode
val appliedMode: Display.Mode,
val isFallback: Boolean
)
private var pendingAfterSwitch: ((DisplayModeSwitchResult) -> Unit)? = null
private var pendingDisplayId: Int? = null
private var pendingMode: Display.Mode? = null
private var originalModeId: Int? = null
data class FrameRateDetection(
val raw: Float,
val snapped: Float,
val videoWidth: Int? = null,
val videoHeight: Int? = null
val snapped: Float
)
private fun matchesTargetRefresh(refreshRate: Float, target: Float): Boolean {
@ -78,6 +72,35 @@ object FrameRateUtils {
return weight
}
private fun completeSwitch(reason: String) {
Log.d(TAG, "Display mode switch completed ($reason)")
val callback = pendingAfterSwitch
val requestedMode = pendingMode
val realMode = runCatching {
val displayId = pendingDisplayId
if (displayId != null) {
displayManager?.getDisplay(displayId)?.mode
} else {
null
}
}.getOrNull()
val appliedMode = realMode ?: requestedMode
val isFallback = requestedMode != null && realMode != null && realMode.modeId != requestedMode.modeId
cleanupDisplayListener()
if (callback != null && appliedMode != null) {
callback(DisplayModeSwitchResult(appliedMode = appliedMode, isFallback = isFallback))
}
}
private fun scheduleSwitchTimeout() {
timeoutHandler = Handler(Looper.getMainLooper())
timeoutRunnable = Runnable {
Log.w(TAG, "Display mode switch timeout after ${SWITCH_TIMEOUT_MS}ms")
completeSwitch("timeout")
}
timeoutHandler?.postDelayed(timeoutRunnable!!, SWITCH_TIMEOUT_MS)
}
private fun recordOriginalMode(display: Display) {
if (originalModeId == null) {
originalModeId = display.mode.modeId
@ -128,153 +151,109 @@ object FrameRateUtils {
}
}
private fun chooseBestModeForFrameRate(
activeMode: Display.Mode,
modes: List<Display.Mode>,
frameRate: Float
): Display.Mode {
val modeExact = pickBestForTarget(modes, frameRate)
val modeDouble = pickBestForTarget(modes, frameRate * 2f)
val modePulldown = pickBestForTarget(modes, frameRate * 2.5f)
val modeFallback = modes.minByOrNull { refreshWeight(it.refreshRate, frameRate) }
return modeExact ?: modeDouble ?: modePulldown ?: modeFallback ?: activeMode
}
private fun hasValidVideoSize(videoWidth: Int?, videoHeight: Int?): Boolean {
return (videoWidth ?: 0) > 0 && (videoHeight ?: 0) > 0
}
private fun normalizedSize(width: Int, height: Int): Pair<Int, Int> {
return if (width >= height) width to height else height to width
}
private fun resolutionDistanceSquared(mode: Display.Mode, targetWidth: Int, targetHeight: Int): Long {
val (modeWidth, modeHeight) = normalizedSize(mode.physicalWidth, mode.physicalHeight)
val dw = modeWidth - targetWidth
val dh = modeHeight - targetHeight
return dw.toLong() * dw.toLong() + dh.toLong() * dh.toLong()
}
private fun selectModesForVideoResolution(
modes: List<Display.Mode>,
videoWidth: Int,
videoHeight: Int
): List<Display.Mode> {
if (modes.isEmpty()) return modes
val (targetWidth, targetHeight) = normalizedSize(videoWidth, videoHeight)
val minDistance = modes.minOfOrNull { resolutionDistanceSquared(it, targetWidth, targetHeight) } ?: return modes
return modes.filter { resolutionDistanceSquared(it, targetWidth, targetHeight) == minDistance }
}
suspend fun matchFrameRateAndWait(
fun matchFrameRate(
activity: Activity,
frameRate: Float,
videoWidth: Int? = null,
videoHeight: Int? = null,
resolutionMatchingEnabled: Boolean = false
): DisplayModeSwitchResult? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null
if (frameRate <= 0f) return null
onBeforeSwitch: (() -> Unit)? = null,
onAfterSwitch: ((DisplayModeSwitchResult) -> Unit)? = null
): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
if (frameRate <= 0f) return false
val switchPlan = withContext(Dispatchers.Main) {
val window = activity.window ?: return@withContext null
val display = window.decorView.display ?: return@withContext null
return try {
val window = activity.window ?: return false
val display = window.decorView.display ?: return false
val supportedModes = display.supportedModes
val activeMode = display.mode
val sameSizeModes = display.supportedModes.filter {
if (supportedModes.size <= 1) return false
val sameSizeModes = supportedModes.filter {
it.physicalWidth == activeMode.physicalWidth &&
it.physicalHeight == activeMode.physicalHeight
}
val candidateModes = if (resolutionMatchingEnabled && hasValidVideoSize(videoWidth, videoHeight)) {
selectModesForVideoResolution(
modes = display.supportedModes.toList(),
videoWidth = videoWidth ?: activeMode.physicalWidth,
videoHeight = videoHeight ?: activeMode.physicalHeight
if (sameSizeModes.size <= 1) return false
// Kodi-like priority without user whitelist:
// exact -> 2x -> 3:2 pulldown -> weighted fallback.
val modeExact = pickBestForTarget(sameSizeModes, frameRate)
val modeDouble = pickBestForTarget(sameSizeModes, frameRate * 2f)
val modePulldown = pickBestForTarget(sameSizeModes, frameRate * 2.5f)
val modeFallback = sameSizeModes.minByOrNull { refreshWeight(it.refreshRate, frameRate) }
val modeBest = modeExact ?: modeDouble ?: modePulldown ?: modeFallback ?: activeMode
val switchNeeded = modeBest.modeId != activeMode.modeId
if (switchNeeded) {
Log.d(
TAG,
"Switching display mode: ${activeMode.refreshRate}Hz -> ${modeBest.refreshRate}Hz " +
"(video ${frameRate}fps)"
)
} else {
sameSizeModes
}
if (candidateModes.isEmpty()) {
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
null,
DisplayModeSwitchResult(activeMode)
)
}
if (!resolutionMatchingEnabled && candidateModes.size <= 1) {
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
null,
DisplayModeSwitchResult(activeMode)
)
}
val modeBest = chooseBestModeForFrameRate(
activeMode = activeMode,
modes = candidateModes,
frameRate = frameRate
)
recordOriginalMode(display)
if (modeBest.modeId == activeMode.modeId) {
Log.d(TAG, "Display already at optimal rate ${activeMode.refreshRate}Hz for ${frameRate}fps")
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
null,
DisplayModeSwitchResult(activeMode)
)
}
cleanupDisplayListener()
recordOriginalMode(display)
Log.d(
TAG,
"Switching display mode: ${activeMode.refreshRate}Hz -> ${modeBest.refreshRate}Hz " +
"(video ${frameRate}fps)"
)
var completeImmediately = false
if (onAfterSwitch != null) {
pendingAfterSwitch = onAfterSwitch
pendingMode = modeBest
pendingDisplayId = display.displayId
displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
if (displayId != pendingDisplayId) return
completeSwitch("displayChanged")
}
}
if (displayManager != null) {
displayManager?.registerDisplayListener(
displayListener,
Handler(Looper.getMainLooper())
)
scheduleSwitchTimeout()
} else {
completeImmediately = true
}
}
val layoutParams = window.attributes
layoutParams.preferredDisplayModeId = modeBest.modeId
window.attributes = layoutParams
Pair(modeBest, null)
} ?: return null
onBeforeSwitch?.invoke()
val immediateResult = switchPlan.second
if (immediateResult != null) return immediateResult
val layoutParams = window.attributes
layoutParams.preferredDisplayModeId = modeBest.modeId
window.attributes = layoutParams
val expectedMode = switchPlan.first ?: return null
var stablePolls = 0
var lastMode: Display.Mode? = null
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < SWITCH_TIMEOUT_MS) {
val mode = withContext(Dispatchers.Main) {
activity.window?.decorView?.display?.mode
} ?: break
lastMode = mode
val modeStable =
mode.modeId == expectedMode.modeId ||
matchesTargetRefresh(mode.refreshRate, expectedMode.refreshRate)
if (modeStable) {
stablePolls += 1
if (stablePolls >= SWITCH_REQUIRED_STABLE_POLLS) {
Log.d(
TAG,
"Display mode switch stabilized at ${mode.refreshRate}Hz (modeId=${mode.modeId})"
)
return DisplayModeSwitchResult(mode)
if (completeImmediately) {
completeSwitch("noDisplayManager")
}
} else {
stablePolls = 0
Log.d(TAG, "Display already at optimal rate ${activeMode.refreshRate}Hz for ${frameRate}fps")
}
delay(SWITCH_POLL_INTERVAL_MS)
}
val fallbackMode = lastMode ?: expectedMode
Log.w(
TAG,
"Display mode polling timed out after ${SWITCH_TIMEOUT_MS}ms, using ${fallbackMode.refreshRate}Hz"
)
return DisplayModeSwitchResult(fallbackMode)
switchNeeded
} catch (e: Exception) {
Log.e(TAG, "Failed to match frame rate", e)
if (pendingAfterSwitch != null) {
completeSwitch("error")
}
false
}
}
fun cleanupDisplayListener() {
// Kept for API compatibility with existing call sites.
timeoutRunnable?.let { timeoutHandler?.removeCallbacks(it) }
timeoutRunnable = null
timeoutHandler = null
displayListener?.let { displayManager?.unregisterDisplayListener(it) }
displayListener = null
displayManager = null
pendingAfterSwitch = null
pendingDisplayId = null
pendingMode = null
}
fun clearOriginalDisplayMode() {
@ -337,93 +316,10 @@ object FrameRateUtils {
return snapToStandardRate(measuredFps)
}
private fun isValidVideoFrameRate(frameRate: Float): Boolean {
return frameRate.isFinite() && frameRate in MIN_VALID_VIDEO_FPS..MAX_VALID_VIDEO_FPS
}
fun detectFrameRateFromSource(
context: Context,
sourceUrl: String,
headers: Map<String, String> = emptyMap()
): FrameRateDetection? {
detectFrameRateFromNextLib(context, sourceUrl, headers)?.let { return it }
return detectFrameRateFromExtractor(context, sourceUrl, headers)
}
fun detectFrameRateFromNextLib(
context: Context,
sourceUrl: String,
headers: Map<String, String> = emptyMap()
): FrameRateDetection? {
return detectFrameRateWithNextLib(context, sourceUrl, headers)
}
fun detectFrameRateFromExtractor(
context: Context,
sourceUrl: String,
headers: Map<String, String> = emptyMap()
): FrameRateDetection? {
if (isResolveProxyUrl(sourceUrl)) {
val embeddedResolveUrl = extractEmbeddedResolveUrl(sourceUrl)
if (!embeddedResolveUrl.isNullOrBlank()) {
detectFrameRateWithExtractor(context, embeddedResolveUrl, headers)?.let { return it }
}
}
return detectFrameRateWithExtractor(context, sourceUrl, headers)
}
private fun detectFrameRateWithNextLib(
context: Context,
sourceUrl: String,
headers: Map<String, String>
): FrameRateDetection? {
if (!shouldUseNextLibProbe(sourceUrl, headers)) return null
val embeddedResolveUrl = extractEmbeddedResolveUrl(sourceUrl)
val shouldPreferEmbedded = isResolveProxyUrl(sourceUrl)
val candidates = buildList {
if (shouldPreferEmbedded && !embeddedResolveUrl.isNullOrBlank() && embeddedResolveUrl != sourceUrl) {
add(embeddedResolveUrl)
add(sourceUrl)
return@buildList
}
add(sourceUrl)
if (!embeddedResolveUrl.isNullOrBlank() && embeddedResolveUrl != sourceUrl) {
add(embeddedResolveUrl)
}
}
candidates.forEach { candidateUrl ->
var mediaInfo: MediaInfo? = null
try {
val uri = Uri.parse(candidateUrl)
val builder = MediaInfoBuilder().from(context = context, uri = uri)
mediaInfo = builder.build() ?: return@forEach
val video = mediaInfo.videoStream ?: return@forEach
val measured = video.frameRate.toFloat()
if (!isValidVideoFrameRate(measured)) return@forEach
return FrameRateDetection(
raw = measured,
snapped = snapToStandardRate(measured),
videoWidth = video.frameWidth.takeIf { it > 0 },
videoHeight = video.frameHeight.takeIf { it > 0 }
)
} catch (e: Throwable) {
Log.w(TAG, "NextLib frame rate probe failed: ${e.message}")
} finally {
runCatching { mediaInfo?.release() }
}
}
return null
}
private fun detectFrameRateWithExtractor(
context: Context,
sourceUrl: String,
headers: Map<String, String>
): FrameRateDetection? {
val extractor = MediaExtractor()
return try {
@ -434,45 +330,19 @@ object FrameRateUtils {
}
var videoTrackIndex = -1
var videoFormat: android.media.MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(android.media.MediaFormat.KEY_MIME)
if (mime?.startsWith("video/") == true) {
videoTrackIndex = i
videoFormat = format
break
}
}
if (videoTrackIndex < 0) return null
val detectedVideoWidth = videoFormat
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_WIDTH) }
?.runCatching { getInteger(android.media.MediaFormat.KEY_WIDTH) }
?.getOrNull()
?.takeIf { it > 0 }
val detectedVideoHeight = videoFormat
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_HEIGHT) }
?.runCatching { getInteger(android.media.MediaFormat.KEY_HEIGHT) }
?.getOrNull()
?.takeIf { it > 0 }
val declaredFrameRate = videoFormat
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_FRAME_RATE) }
?.runCatching { getFloat(android.media.MediaFormat.KEY_FRAME_RATE) }
?.getOrNull()
if (declaredFrameRate != null && isValidVideoFrameRate(declaredFrameRate)) {
return FrameRateDetection(
raw = declaredFrameRate,
snapped = snapToStandardRate(declaredFrameRate),
videoWidth = detectedVideoWidth,
videoHeight = detectedVideoHeight
)
}
extractor.selectTrack(videoTrackIndex)
val timestamps = ArrayList<Long>(400)
val ignoreSamples = 3
val ignoreSamples = 30
val targetSamples = 350 + ignoreSamples
while (timestamps.size < targetSamples) {
@ -490,19 +360,17 @@ object FrameRateUtils {
}
val sampleCount = (timestamps.size - ignoreSamples - 1).coerceAtLeast(1)
if (sampleCount < 30) return null
if (sampleCount < 90) return null
val averageFrameDurationUs = totalFrameDurationUs.toFloat() / sampleCount.toFloat()
if (averageFrameDurationUs <= 0f) return null
val measured = 1_000_000f / averageFrameDurationUs
if (!isValidVideoFrameRate(measured)) return null
if (measured < 10f || measured > 120f) return null
FrameRateDetection(
raw = measured,
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs),
videoWidth = detectedVideoWidth,
videoHeight = detectedVideoHeight
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs)
)
} catch (e: Exception) {
Log.w(TAG, "Frame rate probe failed: ${e.message}")
@ -514,48 +382,4 @@ object FrameRateUtils {
}
}
}
private fun shouldUseNextLibProbe(sourceUrl: String, headers: Map<String, String>): Boolean {
if (sourceUrl.isBlank()) return false
if (isLiveStreamUrl(sourceUrl)) return false
if (isMkvSource(sourceUrl)) return true
val scheme = Uri.parse(sourceUrl).scheme?.lowercase(Locale.ROOT)
return when (scheme) {
in NEXTLIB_HTTP_SCHEMES -> true
"file", "content" -> true
null -> true
else -> false
}
}
private fun isLiveStreamUrl(sourceUrl: String): Boolean {
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
return LIVE_STREAM_EXTENSIONS.any { ext -> normalized.endsWith(ext) }
}
private fun isMkvSource(sourceUrl: String): Boolean {
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
return normalized.endsWith(MKV_EXTENSION)
}
private fun isResolveProxyUrl(sourceUrl: String): Boolean {
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
return "/resolve/" in normalized
}
private fun extractEmbeddedResolveUrl(sourceUrl: String): String? {
val marker = "/resolve/"
val markerIndex = sourceUrl.indexOf(marker, ignoreCase = true)
if (markerIndex < 0) return null
val afterResolve = sourceUrl.substring(markerIndex + marker.length)
val nestedEncoded = afterResolve.substringAfter('/', missingDelimiterValue = "")
.substringAfter('/', missingDelimiterValue = "")
if (nestedEncoded.isBlank()) return null
return runCatching {
URLDecoder.decode(nestedEncoded, StandardCharsets.UTF_8.name())
}.getOrNull()
}
}

View file

@ -1,22 +0,0 @@
package com.nuvio.tv.core.player
import com.nuvio.tv.data.local.PlayerSettings
import com.nuvio.tv.data.local.StreamAutoPlayMode
object StreamAutoPlayPolicy {
fun isEffectivelyEnabled(playerSettings: PlayerSettings): Boolean {
if (playerSettings.streamReuseLastLinkEnabled) return true
return when (playerSettings.streamAutoPlayMode) {
StreamAutoPlayMode.MANUAL -> false
StreamAutoPlayMode.FIRST_STREAM -> true
StreamAutoPlayMode.REGEX_MATCH -> isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex)
}
}
fun isRegexSelectionConfigured(regexPattern: String): Boolean {
val pattern = regexPattern.trim()
if (pattern.isEmpty() || !pattern.any { it.isLetterOrDigit() }) return false
return runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.isSuccess
}
}

View file

@ -17,11 +17,6 @@ object StreamAutoPlaySelector {
return orderedAddons + pluginEntries
}
private fun isPlayable(stream: Stream): Boolean =
stream.getStreamUrl() != null || stream.isTorrent()
fun selectAutoPlayStream(
streams: List<Stream>,
mode: StreamAutoPlayMode,
@ -30,8 +25,7 @@ object StreamAutoPlaySelector {
installedAddonNames: Set<String>,
selectedAddons: Set<String>,
selectedPlugins: Set<String>,
preferredBingeGroup: String? = null,
preferBingeGroupInSelection: Boolean = false
preferredBingeGroup: String? = null
): Stream? {
if (streams.isEmpty()) return null
@ -52,22 +46,22 @@ object StreamAutoPlaySelector {
if (mode == StreamAutoPlayMode.MANUAL) return null
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
if (targetBingeGroup.isNotEmpty()) {
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
stream.behaviorHints?.bingeGroup == targetBingeGroup && isPlayable(stream)
stream.behaviorHints?.bingeGroup == targetBingeGroup && stream.getStreamUrl() != null
}
if (bingeGroupMatch != null) return bingeGroupMatch
}
return when (mode) {
StreamAutoPlayMode.MANUAL -> null
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { isPlayable(it) }
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.getStreamUrl() != null }
StreamAutoPlayMode.REGEX_MATCH -> {
val pattern = regexPattern.trim()
if (pattern.isBlank()) return null
// Try to compile the user regex
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull()
if (userRegex == null) return null
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull() ?: return null
// Auto-extract exclusion patterns from negative lookaheads
val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern)
@ -82,34 +76,31 @@ object StreamAutoPlaySelector {
Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE)
} else null
// 1. Build list of ALL regexmatching streams
val matchingStreams = candidateStreams.filter { stream ->
if (!isPlayable(stream)) return@filter false
candidateStreams.firstOrNull { stream ->
val url = stream.getStreamUrl() ?: return@firstOrNull false
val searchableText = buildString {
append(stream.addonName).append(' ')
append(stream.name.orEmpty()).append(' ')
append(stream.title.orEmpty()).append(' ')
append(stream.description.orEmpty()).append(' ')
append(stream.getStreamUrl().orEmpty())
if (stream.isTorrent()) append(' ').append(stream.infoHash.orEmpty())
append(url)
}
// Must match user include pattern
if (!userRegex.containsMatchIn(searchableText)) return@firstOrNull false
// Must match include pattern
if (!userRegex.containsMatchIn(searchableText)) return@filter false
// Must NOT match exclusion pattern
// Must NOT match user exclusion pattern (if any)
if (excludeRegex != null && excludeRegex.containsMatchIn(searchableText)) {
return@filter false
return@firstOrNull false
}
true
}
if (matchingStreams.isEmpty()) return null
matchingStreams.firstOrNull { isPlayable(it) }
}
}
}
}

View file

@ -1,27 +1,16 @@
package com.nuvio.tv.core.plugin
import android.util.Log
import com.lagradost.cloudstream3.TvType
import com.nuvio.tv.core.plugin.cloudstream.toNuvioType
import com.nuvio.tv.core.plugin.cloudstream.tvTypeFromString
import com.nuvio.tv.core.plugin.cloudstream.ExternalExtensionLoader
import com.nuvio.tv.core.plugin.cloudstream.ExternalExtensionRunner
import com.nuvio.tv.core.plugin.cloudstream.ExternalRepoParser
import com.nuvio.tv.data.local.PluginDataStore
import com.nuvio.tv.domain.model.ExternalPluginEntry
import com.nuvio.tv.domain.model.LocalScraperResult
import com.nuvio.tv.domain.model.PluginManifest
import com.nuvio.tv.domain.model.PluginRepository
import com.nuvio.tv.domain.model.RemotePluginInfo
import com.nuvio.tv.domain.model.RepositoryType
import com.nuvio.tv.domain.model.ScraperInfo
import com.nuvio.tv.domain.model.ScraperManifestInfo
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.channels.Channel
@ -33,7 +22,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.security.MessageDigest
@ -47,7 +35,6 @@ private const val TAG = "PluginManager"
private const val MAX_CONCURRENT_SCRAPERS = 5
private const val MAX_RESULT_ITEMS = 150
private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L
private const val SCRAPER_TIMEOUT_MS = 30_000L
private const val MANIFEST_SUFFIX = "/manifest.json"
@Singleton
@ -55,10 +42,7 @@ class PluginManager @Inject constructor(
private val dataStore: PluginDataStore,
private val runtime: PluginRuntime,
private val pluginSyncService: com.nuvio.tv.core.sync.PluginSyncService,
private val authManager: com.nuvio.tv.core.auth.AuthManager,
private val externalRepoParser: ExternalRepoParser,
private val externalExtensionLoader: ExternalExtensionLoader,
private val externalExtensionRunner: ExternalExtensionRunner
private val authManager: com.nuvio.tv.core.auth.AuthManager
) {
private val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
@ -67,7 +51,6 @@ class PluginManager @Inject constructor(
private val manifestAdapter = moshi.adapter(PluginManifest::class.java)
private val httpClient = OkHttpClient.Builder()
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
@ -82,96 +65,8 @@ class PluginManager @Inject constructor(
return sb.toString()
}
/**
* Normalize custom protocol schemes to https://.
* External repos often use schemes like "cloudstreamrepo://" or "stremio://".
*/
private fun sanitizeScheme(url: String): String {
val trimmed = url.trim()
// Replace any non-http(s) scheme with https://
val schemeEnd = trimmed.indexOf("://")
if (schemeEnd > 0) {
val scheme = trimmed.substring(0, schemeEnd).lowercase()
if (scheme != "http" && scheme != "https") {
return "https://${trimmed.substring(schemeEnd + 3)}"
}
}
return trimmed
}
/**
* Check if the input looks like a short code rather than a URL.
* Short codes are alphanumeric strings without slashes, dots (other than in a domain),
* or protocol schemes e.g. "cspr", "0094", "megarepo".
*/
private fun isShortCode(input: String): Boolean {
val trimmed = input.trim()
if (trimmed.isEmpty()) return false
// Has a scheme → not a short code
if (trimmed.contains("://")) return false
// Has path separators or dots → likely a URL or domain
if (trimmed.contains("/") || trimmed.contains(".")) return false
// Only alphanumeric + hyphens + underscores → short code
return trimmed.all { it.isLetterOrDigit() || it == '-' || it == '_' }
}
/**
* Resolve a short code by following the redirect from cutt.ly/{code}.
* Returns the resolved URL or null if resolution fails.
*/
private fun resolveShortCode(code: String): String? {
return try {
// Use a client that does NOT follow redirects so we can read the Location header
val noRedirectClient = httpClient.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
val request = Request.Builder()
.url("https://cutt.ly/$code")
.header("User-Agent", "NuvioTV/1.0")
.build()
noRedirectClient.newCall(request).execute().use { response ->
if (response.code in 301..302) {
val location = response.header("Location")
if (!location.isNullOrBlank()) {
Log.d(TAG, "Short code '$code' resolved to: $location")
return sanitizeScheme(location)
}
}
// Some shorteners return 200 with a meta refresh or JS redirect
// Try following redirects as fallback
Log.d(TAG, "Short code '$code' returned ${response.code}, trying with redirects")
null
}
} catch (e: Exception) {
Log.e(TAG, "Failed to resolve short code '$code': ${e.message}")
null
} ?: try {
// Fallback: follow redirects and see where we end up
val request = Request.Builder()
.url("https://cutt.ly/$code")
.header("User-Agent", "NuvioTV/1.0")
.build()
httpClient.newCall(request).execute().use { response ->
val finalUrl = response.request.url.toString()
if (finalUrl != "https://cutt.ly/$code" && response.isSuccessful) {
Log.d(TAG, "Short code '$code' resolved via redirect chain to: $finalUrl")
sanitizeScheme(finalUrl)
} else {
null
}
}
} catch (e: Exception) {
Log.e(TAG, "Fallback resolve for short code '$code' failed: ${e.message}")
null
}
}
private fun canonicalizeManifestUrl(url: String): String {
val trimmed = sanitizeScheme(url).trimEnd('/')
val trimmed = url.trim().trimEnd('/')
return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) {
trimmed
} else {
@ -179,21 +74,7 @@ class PluginManager @Inject constructor(
}
}
/**
* Canonicalize a URL for deduplication. For NuvioTV-style URLs (that don't end in .json),
* appends /manifest.json. For URLs already ending in .json (external repos), keeps them as-is.
*/
private fun canonicalizeRepoUrl(url: String): String {
val trimmed = sanitizeScheme(url).trimEnd('/')
// If URL already ends with a .json file, it's likely an external repo URL — keep as-is
if (trimmed.substringAfterLast("/").endsWith(".json", ignoreCase = true)) {
return trimmed
}
// Otherwise canonicalize as NuvioTV manifest
return canonicalizeManifestUrl(trimmed)
}
private fun normalizeUrl(url: String): String = canonicalizeRepoUrl(url).lowercase()
private fun normalizeUrl(url: String): String = canonicalizeManifestUrl(url).lowercase()
// Single-flight map to prevent duplicate scraper executions
private val inFlightScrapers = ConcurrentHashMap<String, kotlinx.coroutines.Deferred<List<LocalScraperResult>>>()
@ -216,30 +97,11 @@ class PluginManager @Inject constructor(
var isSyncingFromRemote = false
/** Prevents concurrent reconciliation from StartupSyncService and AccountViewModel */
private val reconcileMutex = Mutex()
@Volatile
private var pendingPushAfterSync = false
/**
* Call after setting isSyncingFromRemote = false to push any changes
* that were made during reconciliation (e.g. repo removals).
*/
fun flushPendingSync() {
if (pendingPushAfterSync) {
pendingPushAfterSync = false
Log.d(TAG, "flushPendingSync: firing deferred push")
triggerRemoteSync()
}
}
private var syncJob: kotlinx.coroutines.Job? = null
private fun triggerRemoteSync() {
if (isSyncingFromRemote) {
Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote), will push after sync")
pendingPushAfterSync = true
Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote)")
return
}
if (!authManager.isAuthenticated) {
@ -264,205 +126,73 @@ class PluginManager @Inject constructor(
}
/**
* Add a new repository from manifest URL.
* Auto-detects format: tries NuvioTV manifest first, then external repo format.
* Add a new repository from manifest URL
*/
suspend fun addRepository(manifestUrl: String): Result<PluginRepository> = withContext(Dispatchers.IO) {
try {
// Resolve short codes (e.g. "cspr", "0094") via cutt.ly redirect
val resolvedUrl = if (isShortCode(manifestUrl)) {
Log.d(TAG, "Input looks like a short code: '$manifestUrl'")
resolveShortCode(manifestUrl.trim())
?: return@withContext Result.failure(
Exception("Failed to resolve short code: $manifestUrl")
)
} else {
sanitizeScheme(manifestUrl).trimEnd('/')
}
val sanitizedUrl = resolvedUrl.trimEnd('/')
val filename = sanitizedUrl.substringAfterLast("/")
val isExplicitJsonFile = filename.endsWith(".json", ignoreCase = true)
&& !filename.equals("manifest.json", ignoreCase = true)
// If the URL points to a specific .json file (not manifest.json),
// try external format first to avoid a wasted 404 on the NuvioTV path.
if (isExplicitJsonFile) {
Log.d(TAG, "URL ends in .json — trying external format first: $sanitizedUrl")
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
if (externalResult != null) {
return@withContext addExternalRepository(sanitizedUrl, externalResult)
}
}
// Try NuvioTV format (with canonicalized /manifest.json URL)
val canonicalManifestUrl = canonicalizeManifestUrl(sanitizedUrl)
Log.d(TAG, "Trying NuvioTV manifest: $canonicalManifestUrl")
val canonicalManifestUrl = canonicalizeManifestUrl(manifestUrl)
Log.d(TAG, "Adding repository from: $canonicalManifestUrl")
// Fetch manifest
val manifest = fetchManifest(canonicalManifestUrl)
if (manifest != null) {
return@withContext addNuvioRepository(canonicalManifestUrl, manifest)
}
?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
// Create repository
val repo = PluginRepository(
id = UUID.randomUUID().toString(),
name = manifest.name,
url = canonicalManifestUrl,
enabled = true,
lastUpdated = System.currentTimeMillis(),
scraperCount = manifest.scrapers.size
)
// Save repository
dataStore.addRepository(repo)
// Download and save scrapers
downloadScrapers(repo.id, canonicalManifestUrl, manifest.scrapers)
Log.d(TAG, "Repository added: ${repo.name} with ${manifest.scrapers.size} scrapers")
triggerRemoteSync()
Result.success(repo)
// If we haven't tried external format yet, try it now
if (!isExplicitJsonFile) {
Log.d(TAG, "NuvioTV manifest not found, trying external format: $sanitizedUrl")
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
if (externalResult != null) {
return@withContext addExternalRepository(sanitizedUrl, externalResult)
}
}
Result.failure(Exception("Failed to parse repository: unrecognized format"))
} catch (e: Exception) {
Log.e(TAG, "Failed to add repository: ${e.message}", e)
Result.failure(e)
}
}
/**
* Add a repository with a type hint from Supabase sync.
* Skips wrong detection paths when the type is already known,
* making reconciliation faster and more resilient to network issues.
*/
private suspend fun addRepositoryWithTypeHint(
manifestUrl: String,
typeHint: RepositoryType?
): Result<PluginRepository> = withContext(Dispatchers.IO) {
try {
val sanitizedUrl = sanitizeScheme(manifestUrl).trimEnd('/')
when (typeHint) {
RepositoryType.EXTERNAL_DEX -> {
Log.d(TAG, "addRepositoryWithTypeHint: EXTERNAL_DEX hint, trying external format: $sanitizedUrl")
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
if (externalResult != null) {
return@withContext addExternalRepository(sanitizedUrl, externalResult)
}
// Hint was wrong or parse failed — fall through to auto-detect
Log.w(TAG, "addRepositoryWithTypeHint: EXTERNAL_DEX hint failed, falling back to auto-detect")
}
RepositoryType.NUVIO_JS -> {
Log.d(TAG, "addRepositoryWithTypeHint: NUVIO_JS hint, trying manifest: $sanitizedUrl")
val canonicalManifestUrl = canonicalizeManifestUrl(sanitizedUrl)
val manifest = fetchManifest(canonicalManifestUrl)
if (manifest != null) {
return@withContext addNuvioRepository(canonicalManifestUrl, manifest)
}
Log.w(TAG, "addRepositoryWithTypeHint: NUVIO_JS hint failed, falling back to auto-detect")
}
null -> { /* No hint — use auto-detect */ }
}
// Fall back to full auto-detection
addRepository(sanitizedUrl)
} catch (e: Exception) {
Log.e(TAG, "Failed to add repository with hint: ${e.message}", e)
Result.failure(e)
}
}
private suspend fun addNuvioRepository(
canonicalManifestUrl: String,
manifest: PluginManifest
): Result<PluginRepository> {
val repo = PluginRepository(
id = UUID.randomUUID().toString(),
name = manifest.name,
url = canonicalManifestUrl,
enabled = true,
lastUpdated = System.currentTimeMillis(),
scraperCount = manifest.scrapers.size,
type = RepositoryType.NUVIO_JS
)
dataStore.addRepository(repo)
downloadJsScrapers(repo.id, canonicalManifestUrl, manifest.scrapers)
Log.d(TAG, "NuvioTV repository added: ${repo.name} with ${manifest.scrapers.size} scrapers")
triggerRemoteSync()
return Result.success(repo)
}
private suspend fun addExternalRepository(
repoUrl: String,
parseResult: com.nuvio.tv.core.plugin.cloudstream.ExternalRepoParseResult
): Result<PluginRepository> {
// Prevent duplicate repos by URL
val existingRepo = dataStore.repositories.first()
.find { normalizeUrl(it.url) == normalizeUrl(repoUrl) }
if (existingRepo != null) {
Log.d(TAG, "External repository already exists: ${existingRepo.name} (${existingRepo.url})")
return Result.success(existingRepo)
}
val repo = PluginRepository(
id = UUID.randomUUID().toString(),
name = parseResult.name,
url = repoUrl,
description = parseResult.description,
enabled = true,
lastUpdated = System.currentTimeMillis(),
scraperCount = parseResult.plugins.size,
type = RepositoryType.EXTERNAL_DEX
)
dataStore.addRepository(repo)
downloadDexExtensions(repo.id, parseResult.plugins)
Log.d(TAG, "External repository added: ${repo.name} with ${parseResult.plugins.size} extensions")
triggerRemoteSync()
return Result.success(repo)
}
/**
* Remove a repository and its scrapers
*/
suspend fun removeRepository(repoId: String) {
val scraperList = dataStore.scrapers.first()
val repo = dataStore.repositories.first().find { it.id == repoId }
// Remove all scrapers from this repo
scraperList.filter { it.repositoryId == repoId }.forEach { scraper ->
if (scraper.type == RepositoryType.EXTERNAL_DEX || repo?.type == RepositoryType.EXTERNAL_DEX) {
externalExtensionLoader.deleteExtension(scraper.id)
} else {
dataStore.deleteScraperCode(scraper.id)
}
dataStore.deleteScraperCode(scraper.id)
}
// Remove scrapers from list
val updatedScrapers = scraperList.filter { it.repositoryId != repoId }
dataStore.saveScrapers(updatedScrapers)
// Remove repository
dataStore.removeRepository(repoId)
// Push synchronously when user-initiated (not during reconciliation)
// to prevent the next sync pull from re-adding the removed repo
if (!isSyncingFromRemote && authManager.isAuthenticated) {
Log.d(TAG, "removeRepository: pushing removal to remote synchronously")
pluginSyncService.pushToRemote()
} else if (isSyncingFromRemote) {
pendingPushAfterSync = true
}
triggerRemoteSync()
}
/**
* Reconcile local plugin repos with the remote list from Supabase.
* @param remotePlugins list of remote plugin info (URL + optional type hint)
* @param removeMissingLocal if true, remove local repos not in the remote list
*/
suspend fun reconcileWithRemoteRepoUrls(
remotePlugins: List<RemotePluginInfo>,
remoteUrls: List<String>,
removeMissingLocal: Boolean = true
) = reconcileMutex.withLock {
val normalizedRemote = remotePlugins
.map { it.copy(url = canonicalizeRepoUrl(it.url)) }
.filter { it.url.isNotEmpty() }
.distinctBy { normalizeUrl(it.url) }
val remoteUrlSet = normalizedRemote.map { normalizeUrl(it.url) }.toSet()
) {
val normalizedRemote = remoteUrls
.map { canonicalizeManifestUrl(it) }
.filter { it.isNotEmpty() }
.distinctBy { normalizeUrl(it) }
val remoteUrlSet = normalizedRemote.map { normalizeUrl(it) }.toSet()
val initialLocalRepos = dataStore.repositories.first()
val initialLocalByNormalizedUrl = initialLocalRepos.associateBy { normalizeUrl(it.url) }
@ -479,28 +209,19 @@ class PluginManager @Inject constructor(
if (shouldRemoveMissingLocal) {
initialLocalRepos
.filter { normalizeUrl(it.url) !in remoteUrlSet }
.forEach { repo ->
Log.d(TAG, "reconcile: removing local repo not in remote: ${repo.name} (${repo.url})")
removeRepository(repo.id)
}
.forEach { repo -> removeRepository(repo.id) }
}
normalizedRemote.forEach { remotePlugin ->
if (initialLocalByNormalizedUrl[normalizeUrl(remotePlugin.url)] == null) {
val typeHint = remotePlugin.repoType?.let {
try { RepositoryType.valueOf(it) } catch (_: Exception) { null }
}
val result = addRepositoryWithTypeHint(remotePlugin.url, typeHint)
if (result.isFailure) {
Log.e(TAG, "reconcile: failed to add repo ${remotePlugin.url}: ${result.exceptionOrNull()?.message}")
}
normalizedRemote.forEach { remoteUrl ->
if (initialLocalByNormalizedUrl[normalizeUrl(remoteUrl)] == null) {
addRepository(remoteUrl)
}
}
val currentRepos = dataStore.repositories.first()
val currentByNormalizedUrl = currentRepos.associateBy { normalizeUrl(it.url) }
val remoteOrderedRepos = normalizedRemote
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it.url)] }
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it)] }
val extras = currentRepos
.filter { normalizeUrl(it.url) !in remoteUrlSet }
@ -509,18 +230,6 @@ class PluginManager @Inject constructor(
dataStore.saveRepositories(reordered)
}
}
/** Convenience overload for plain URL lists (no type hints) */
@JvmName("reconcileWithRemoteRepoUrlStrings")
suspend fun reconcileWithRemoteRepoUrls(
remoteUrls: List<String>,
removeMissingLocal: Boolean = true
) {
reconcileWithRemoteRepoUrls(
remotePlugins = remoteUrls.map { RemotePluginInfo(url = it) },
removeMissingLocal = removeMissingLocal
)
}
/**
* Refresh a repository - re-download manifest and scrapers
@ -529,14 +238,10 @@ class PluginManager @Inject constructor(
try {
val repo = dataStore.repositories.first().find { it.id == repoId }
?: return@withContext Result.failure(Exception("Repository not found"))
if (repo.type == RepositoryType.EXTERNAL_DEX) {
return@withContext refreshExternalRepository(repo)
}
val manifest = fetchManifest(repo.url)
?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
// Update repository
val updatedRepo = repo.copy(
name = manifest.name,
@ -544,36 +249,16 @@ class PluginManager @Inject constructor(
scraperCount = manifest.scrapers.size
)
dataStore.updateRepository(updatedRepo)
// Re-download scrapers
downloadJsScrapers(repo.id, repo.url, manifest.scrapers)
downloadScrapers(repo.id, repo.url, manifest.scrapers)
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to refresh repository: ${e.message}", e)
Result.failure(e)
}
}
private suspend fun refreshExternalRepository(repo: PluginRepository): Result<Unit> {
val parseResult = externalRepoParser.tryParse(repo.url)
?: return Result.failure(Exception("Failed to parse external repository"))
// Evict stale class loaders for old scrapers
val oldScrapers = dataStore.scrapers.first().filter { it.repositoryId == repo.id }
oldScrapers.forEach { externalExtensionLoader.evictCache(it.id) }
val updatedRepo = repo.copy(
name = parseResult.name,
lastUpdated = System.currentTimeMillis(),
scraperCount = parseResult.plugins.size
)
dataStore.updateRepository(updatedRepo)
downloadDexExtensions(repo.id, parseResult.plugins)
return Result.success(Unit)
}
/**
* Toggle scraper enabled state
@ -585,18 +270,7 @@ class PluginManager @Inject constructor(
}
dataStore.saveScrapers(updatedScrapers)
}
/**
* Toggle all scrapers belonging to a repository
*/
suspend fun toggleAllScrapersForRepo(repoId: String, enabled: Boolean) {
val scraperList = dataStore.scrapers.first()
val updatedScrapers = scraperList.map { scraper ->
if (scraper.repositoryId == repoId) scraper.copy(enabled = enabled) else scraper
}
dataStore.saveScrapers(updatedScrapers)
}
/**
* Toggle plugins globally enabled
*/
@ -625,20 +299,7 @@ class PluginManager @Inject constructor(
}
Log.d(TAG, "Executing ${enabledScraperList.size} scrapers for $mediaType:$tmdbId")
// Preload all extractors from EXTERNAL_DEX repos before any scraper runs
val dexScraperIds = enabledScraperList
.filter { it.type == RepositoryType.EXTERNAL_DEX }
.map { it.id }
if (dexScraperIds.isNotEmpty()) {
// Also load ALL dex scrapers from the same repos (not just enabled ones)
// since extractors can live in any .cs3 file
val allDexIds = dataStore.scrapers.first()
.filter { it.type == RepositoryType.EXTERNAL_DEX }
.map { it.id }
externalExtensionLoader.ensureExtractorsLoaded(allDexIds)
}
val results = enabledScraperList.map { scraper ->
async {
executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode)
@ -668,16 +329,7 @@ class PluginManager @Inject constructor(
}
Log.d(TAG, "Streaming execution of ${enabledList.size} scrapers for $mediaType:$tmdbId")
// Preload all extractors from EXTERNAL_DEX repos before any scraper runs
val dexScraperIds = enabledList.filter { it.type == RepositoryType.EXTERNAL_DEX }.map { it.id }
if (dexScraperIds.isNotEmpty()) {
val allDexIds = dataStore.scrapers.first()
.filter { it.type == RepositoryType.EXTERNAL_DEX }
.map { it.id }
externalExtensionLoader.ensureExtractorsLoaded(allDexIds)
}
// Launch all scrapers concurrently within the channelFlow scope
enabledList.forEach { scraper ->
launch {
@ -737,7 +389,7 @@ class PluginManager @Inject constructor(
}
/**
* Execute a single scraper, dispatching by type.
* Execute a single scraper
*/
suspend fun executeScraper(
scraper: ScraperInfo,
@ -745,19 +397,6 @@ class PluginManager @Inject constructor(
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
return when (scraper.type) {
RepositoryType.EXTERNAL_DEX -> executeExternalDexScraper(scraper, tmdbId, mediaType, season, episode)
RepositoryType.NUVIO_JS -> executeJsScraper(scraper, tmdbId, mediaType, season, episode)
}
}
private suspend fun executeJsScraper(
scraper: ScraperInfo,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
return try {
val code = dataStore.getScraperCode(scraper.id)
@ -779,108 +418,45 @@ class PluginManager @Inject constructor(
} catch (_: Exception) {
// ignore
}
val settings = dataStore.getScraperSettings(scraper.id)
Log.d(TAG, "Executing scraper: ${scraper.name}")
val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) {
withContext(Dispatchers.IO) {
runtime.executePlugin(
code = code,
tmdbId = tmdbId,
mediaType = mediaType,
season = season,
episode = episode,
scraperId = scraper.id,
scraperSettings = settings
)
}
}
if (results == null) {
Log.w(TAG, "Scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms")
return emptyList()
}
val results = runtime.executePlugin(
code = code,
tmdbId = tmdbId,
mediaType = mediaType,
season = season,
episode = episode,
scraperId = scraper.id,
scraperSettings = settings
)
Log.d(TAG, "Scraper ${scraper.name} returned ${results.size} results")
results.map { it.copy(provider = scraper.name) }
} catch (e: Exception) {
Log.e(TAG, "Failed to execute scraper ${scraper.name}: ${e.message}", e)
emptyList()
}
}
private suspend fun executeExternalDexScraper(
scraper: ScraperInfo,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
return try {
Log.d(TAG, "Executing DEX scraper: ${scraper.name}")
val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) {
externalExtensionRunner.execute(scraper.id, tmdbId, mediaType, season, episode)
}
if (results == null) {
Log.w(TAG, "DEX scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms")
return emptyList()
}
Log.d(TAG, "DEX scraper ${scraper.name} returned ${results.size} results")
results.map { it.copy(provider = scraper.name) }
} catch (e: Exception) {
Log.e(TAG, "Failed to execute DEX scraper ${scraper.name}: ${e.message}", e)
emptyList()
}
}
/**
* Test a scraper with sample data, returning results along with diagnostic steps.
* Test a scraper with sample data
*/
suspend fun testScraper(scraperId: String): Result<Pair<List<LocalScraperResult>, TestDiagnostics>> {
val diagnostics = TestDiagnostics()
suspend fun testScraper(scraperId: String): Result<List<LocalScraperResult>> {
val scraper = dataStore.scrapers.first().find { it.id == scraperId }
if (scraper == null) {
diagnostics.addStep("Scraper '$scraperId' not found in datastore")
return Result.failure(Exception("Scraper not found"))
}
diagnostics.addStep("Scraper: ${scraper.name} (type=${scraper.type})")
?: return Result.failure(Exception("Scraper not found"))
// Use a popular movie for testing (The Matrix - 603)
val testTmdbId = "603"
val testMediaType = if (scraper.supportsType("movie")) "movie" else "series"
diagnostics.addStep("Test: TMDB $testTmdbId ($testMediaType)")
// Preload extractors from ALL .cs3 files in the same repo(s)
if (scraper.type == RepositoryType.EXTERNAL_DEX) {
val allDexIds = dataStore.scrapers.first()
.filter { it.type == RepositoryType.EXTERNAL_DEX }
.map { it.id }
externalExtensionLoader.ensureExtractorsLoaded(allDexIds, diagnostics)
}
val testSeason = if (testMediaType == "movie") null else 1
val testEpisode = if (testMediaType == "movie") null else 1
return try {
val results = when (scraper.type) {
RepositoryType.EXTERNAL_DEX -> {
externalExtensionRunner.executeWithDiagnostics(
scraper.id, testTmdbId, testMediaType, testSeason, testEpisode, diagnostics
)
}
RepositoryType.NUVIO_JS -> {
diagnostics.addStep("Executing JS scraper...")
executeScraper(scraper, testTmdbId, testMediaType, testSeason, testEpisode)
}
}
diagnostics.addStep("Result: ${results.size} streams")
Result.success(results to diagnostics)
val results = executeScraper(scraper, testTmdbId, testMediaType, 1, 1)
Result.success(results)
} catch (e: Exception) {
diagnostics.addStep("Exception: ${e.javaClass.simpleName}: ${e.message}")
Result.success(emptyList<LocalScraperResult>() to diagnostics)
Result.failure(e)
}
}
@ -907,7 +483,7 @@ class PluginManager @Inject constructor(
}
}
private suspend fun downloadJsScrapers(
private suspend fun downloadScrapers(
repoId: String,
manifestUrl: String,
scraperInfos: List<ScraperManifestInfo>
@ -967,13 +543,6 @@ class PluginManager @Inject constructor(
// Create scraper info
val scraperId = "$repoId:${info.id}"
val existingScraper = existingScrapers.firstOrNull { it.id == scraperId }
val defaultEnabled = info.enabled &&
!PluginSafety.isVideoEasyScraper(
scraperId = info.id,
scraperName = info.name,
filename = info.filename
)
val scraper = ScraperInfo(
id = scraperId,
repositoryId = repoId,
@ -982,7 +551,7 @@ class PluginManager @Inject constructor(
version = info.version,
filename = info.filename,
supportedTypes = info.supportedTypes,
enabled = existingScraper?.enabled ?: defaultEnabled,
enabled = true,
manifestEnabled = info.enabled,
logo = info.logo,
contentLanguage = info.contentLanguage ?: emptyList(),
@ -1005,77 +574,4 @@ class PluginManager @Inject constructor(
dataStore.saveScrapers(existingScrapers)
}
/**
* Download .cs3 DEX extensions in parallel and register them as scrapers.
* Uses a semaphore to limit concurrent downloads and avoid overwhelming
* the network. Scrapers are saved incrementally in batches.
*/
private suspend fun downloadDexExtensions(
repoId: String,
plugins: List<ExternalPluginEntry>
) = withContext(Dispatchers.IO) {
val existingScrapers = dataStore.scrapers.first().toMutableList()
val downloadSemaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
val newScrapers = java.util.Collections.synchronizedList(mutableListOf<ScraperInfo>())
// Download all extensions in parallel with limited concurrency
val jobs = plugins.map { plugin ->
async {
downloadSemaphore.withPermit {
try {
val scraperId = "$repoId:${plugin.internalName}"
val file = externalExtensionLoader.downloadExtension(scraperId, plugin.url)
if (file == null) {
Log.e(TAG, "Failed to download extension: ${plugin.name}")
return@withPermit
}
val supportedTypes = plugin.tvTypes
?.mapNotNull { tvTypeFromString(it) }
?.map { it.toNuvioType() }
?.distinct()
?.ifEmpty { listOf("movie", "tv") }
?: listOf("movie", "tv")
val scraper = ScraperInfo(
id = scraperId,
repositoryId = repoId,
name = plugin.name,
description = plugin.description ?: "",
version = plugin.version.toString(),
filename = plugin.url,
supportedTypes = supportedTypes,
enabled = true,
manifestEnabled = plugin.status == 1,
logo = plugin.iconUrl,
contentLanguage = emptyList(),
formats = null,
type = RepositoryType.EXTERNAL_DEX
)
newScrapers.add(scraper)
Log.d(TAG, "Downloaded DEX extension: ${plugin.name} (${file.length()} bytes)")
} catch (e: Exception) {
Log.e(TAG, "Error downloading extension ${plugin.name}: ${e.message}", e)
}
}
}
}
jobs.awaitAll()
// Merge new scrapers into existing list
val newScraperIds = newScrapers.map { it.id }.toSet()
existingScrapers.removeAll { it.id in newScraperIds }
existingScrapers.addAll(newScrapers)
dataStore.saveScrapers(existingScrapers)
Log.d(TAG, "Downloaded ${newScrapers.size}/${plugins.size} extensions for repo $repoId")
}
companion object {
private const val MAX_PARALLEL_DOWNLOADS = 10
}
}

View file

@ -1,12 +1,12 @@
package com.nuvio.tv.core.plugin
import android.util.Log
import com.dokar.quickjs.binding.asyncFunction
import com.dokar.quickjs.binding.define
import com.dokar.quickjs.binding.function
import com.dokar.quickjs.quickJs
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.domain.model.LocalScraperResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -39,10 +39,7 @@ import javax.inject.Singleton
private const val TAG = "PluginRuntime"
private const val PLUGIN_TIMEOUT_MS = 60_000L
private const val MAX_FETCH_RESPONSE_BYTES = 256 * 1024
private const val MAX_FETCH_BODY_CHARS = 256 * 1024
private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024
private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]"
private const val MAX_FETCH_RESPONSE_BYTES = 2 * 1024 * 1024
@Singleton
class PluginRuntime @Inject constructor() {
@ -50,7 +47,6 @@ class PluginRuntime @Inject constructor() {
private val gson: Gson = GsonBuilder().create()
private val httpClient = OkHttpClient.Builder()
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
@ -59,6 +55,10 @@ class PluginRuntime @Inject constructor() {
.proxy(java.net.Proxy.NO_PROXY)
.build()
// Store parsed documents for cheerio
private val documentCache = ConcurrentHashMap<String, Document>()
private val elementCache = ConcurrentHashMap<String, Element>()
// Pre-compiled regex for :contains() selector conversion
private val containsRegex = Regex(""":contains\(["']([^"']+)["']\)""")
@ -144,99 +144,87 @@ class PluginRuntime @Inject constructor() {
scraperId: String,
scraperSettings: Map<String, Any>
): List<LocalScraperResult> {
val documentCache = ConcurrentHashMap<String, Document>()
val elementCache = ConcurrentHashMap<String, Element>()
// Clear caches before execution
documentCache.clear()
elementCache.clear()
val inFlightCalls = ConcurrentHashMap.newKeySet<Call>()
var resultJson = "[]"
try {
quickJs(Dispatchers.IO) {
// Define console object - must return null to avoid quickjs conversion issues
define("console") {
function("log") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("error") { args ->
Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("warn") { args ->
Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("info") { args ->
Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("debug") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
// Define console object - must return null to avoid quickjs conversion issues
define("console") {
function("log") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("error") { args ->
Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("warn") { args ->
Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("info") { args ->
Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
function("debug") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
null
}
}
function("__native_fetch") { args ->
val url = args.getOrNull(0)?.toString() ?: ""
val method = args.getOrNull(1)?.toString() ?: "GET"
val headersJson = args.getOrNull(2)?.toString() ?: "{}"
val body = args.getOrNull(3)?.toString() ?: ""
try {
performNativeFetch(url, method, headersJson, body, inFlightCalls)
} catch (t: Throwable) {
Log.e(TAG, "Async fetch bridge error for $method $url: ${t.message}")
gson.toJson(
mapOf(
"ok" to false,
"status" to 0,
"statusText" to (t.message ?: "Fetch failed"),
"url" to url,
"body" to "",
"headers" to emptyMap<String, String>()
)
)
// Define native fetch function (async)
asyncFunction("__native_fetch") { args ->
val url = args.getOrNull(0)?.toString() ?: ""
val method = args.getOrNull(1)?.toString() ?: "GET"
val headersJson = args.getOrNull(2)?.toString() ?: "{}"
val body = args.getOrNull(3)?.toString() ?: ""
performNativeFetch(url, method, headersJson, body, inFlightCalls)
}
// Define URL parser
function("__parse_url") { args ->
val urlString = args.getOrNull(0)?.toString() ?: ""
parseUrl(urlString)
}
// Define cheerio load function
function("__cheerio_load") { args ->
val html = args.getOrNull(0)?.toString() ?: ""
val docId = UUID.randomUUID().toString()
val doc = Jsoup.parse(html)
documentCache[docId] = doc
docId
}
// Define cheerio select function
function("__cheerio_select") { args ->
val docId = args.getOrNull(0)?.toString() ?: ""
var selector = args.getOrNull(1)?.toString() ?: ""
val doc = documentCache[docId] ?: return@function "[]"
try {
// Convert cheerio :contains("text") to jsoup :contains(text)
selector = selector.replace(containsRegex, ":contains($1)")
val elements = if (selector.isEmpty()) {
Elements()
} else {
doc.select(selector)
}
}
// Define URL parser
function("__parse_url") { args ->
val urlString = args.getOrNull(0)?.toString() ?: ""
parseUrl(urlString)
}
// Define cheerio load function
function("__cheerio_load") { args ->
val html = args.getOrNull(0)?.toString() ?: ""
val docId = UUID.randomUUID().toString()
val doc = Jsoup.parse(html)
documentCache[docId] = doc
docId
}
// Define cheerio select function
function("__cheerio_select") { args ->
val docId = args.getOrNull(0)?.toString() ?: ""
var selector = args.getOrNull(1)?.toString() ?: ""
val doc = documentCache[docId] ?: return@function "[]"
try {
// Convert cheerio :contains("text") to jsoup :contains(text)
selector = selector.replace(containsRegex, ":contains($1)")
val elements = if (selector.isEmpty()) {
Elements()
} else {
doc.select(selector)
}
val ids = elements.mapIndexed { index, el ->
val elId = "$docId:$index:${el.hashCode()}"
elementCache[elId] = el
elId
}
// Use simple JSON array construction to avoid Gson issues
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
} catch (e: Exception) {
"[]"
val ids = elements.mapIndexed { index, el ->
val elId = "$docId:$index:${el.hashCode()}"
elementCache[elId] = el
elId
}
// Use simple JSON array construction to avoid Gson issues
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
} catch (e: Exception) {
"[]"
}
}
// Define cheerio find function
function("__cheerio_find") { args ->
@ -372,7 +360,7 @@ class PluginRuntime @Inject constructor() {
})();
""".trimIndent()
evaluate<Any?>(callCode)
evaluate<Any?>(callCode)
}
return parseJsonResults(resultJson)
@ -451,10 +439,10 @@ class PluginRuntime @Inject constructor() {
response.use { httpResponse ->
val bodyContentType = httpResponse.body?.contentType()
val contentEncoding = httpResponse.header("Content-Encoding")?.lowercase()?.trim()
val decodedRead = try {
val decodedBytes = try {
val stream = httpResponse.body?.byteStream()
if (stream == null) {
BoundedReadResult(ByteArray(0), false)
ByteArray(0)
} else {
val decodeStream: InputStream = when (contentEncoding) {
"gzip" -> GZIPInputStream(stream)
@ -467,14 +455,18 @@ class PluginRuntime @Inject constructor() {
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read/decode response body for $url: ${e.message}")
BoundedReadResult(ByteArray(0), false)
ByteArray(0)
}
val charset = bodyContentType?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
val responseBody = decodeBodyToSafeString(decodedRead.bytes, charset)
val responseBody = try {
String(decodedBytes, charset)
} catch (e: Exception) {
String(decodedBytes, Charsets.UTF_8)
}
val responseHeaders = mutableMapOf<String, String>()
httpResponse.headers.forEach { (name, value) ->
responseHeaders[name.lowercase()] = truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS)
responseHeaders[name.lowercase()] = value
}
val result = mapOf(
@ -483,8 +475,7 @@ class PluginRuntime @Inject constructor() {
"statusText" to httpResponse.message,
"url" to httpResponse.request.url.toString(),
"body" to responseBody,
"headers" to responseHeaders,
"truncated" to decodedRead.truncated
"headers" to responseHeaders
)
Log.d(TAG, "Fetch result: ${httpResponse.code} ${httpResponse.message} url=$url bodyLen=${responseBody.length} bodyPreview=${responseBody.take(300)}")
@ -506,32 +497,10 @@ class PluginRuntime @Inject constructor() {
}
}
private data class BoundedReadResult(
val bytes: ByteArray,
val truncated: Boolean
)
private fun truncateString(value: String, maxChars: Int): String {
if (value.length <= maxChars) return value
val end = maxChars - FETCH_TRUNCATION_SUFFIX.length
if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars)
return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX
}
private fun decodeBodyToSafeString(bytes: ByteArray, charset: java.nio.charset.Charset): String {
val decoded = try {
String(bytes, charset)
} catch (e: Exception) {
String(bytes, Charsets.UTF_8)
}
return truncateString(decoded, MAX_FETCH_BODY_CHARS)
}
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): BoundedReadResult {
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): ByteArray {
val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024))
val buffer = ByteArray(8 * 1024)
var remaining = maxBytes
var truncated = false
while (remaining > 0) {
val read = stream.read(buffer, 0, minOf(buffer.size, remaining))
@ -539,10 +508,7 @@ class PluginRuntime @Inject constructor() {
out.write(buffer, 0, read)
remaining -= read
}
if (remaining == 0) {
truncated = stream.read() != -1
}
return BoundedReadResult(out.toByteArray(), truncated)
return out.toByteArray()
}
private fun parseUrl(urlString: String): String {
@ -576,16 +542,7 @@ class PluginRuntime @Inject constructor() {
globalThis.SCRAPER_ID = "$scraperId";
globalThis.SCRAPER_SETTINGS = $settingsJson;
if (typeof TMDB_API_KEY === 'undefined') {
globalThis.TMDB_API_KEY = "${BuildConfig.TMDB_API_KEY}";
}
if (typeof globalThis.global === 'undefined') {
globalThis.global = globalThis;
}
if (typeof globalThis.window === 'undefined') {
globalThis.window = globalThis;
}
if (typeof globalThis.self === 'undefined') {
globalThis.self = globalThis;
globalThis.TMDB_API_KEY = "1865f43a0549ca50d341dd9ab8b29f49";
}
// Fetch implementation (async)
@ -607,7 +564,7 @@ class PluginRuntime @Inject constructor() {
headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
}
var result = __native_fetch(url, method, JSON.stringify(headers), body);
var result = await __native_fetch(url, method, JSON.stringify(headers), body);
var parsed = JSON.parse(result);
if (signal && signal.aborted) {
@ -630,15 +587,10 @@ class PluginRuntime @Inject constructor() {
return Promise.resolve(parsed.body);
},
json: function() {
try {
if (parsed.body === null || parsed.body === undefined || parsed.body === '') {
return Promise.resolve(null);
}
return Promise.resolve(JSON.parse(parsed.body));
} catch (e) {
console.error('fetch.json parse error:', e && e.message ? e.message : e);
return Promise.resolve(null);
return Promise.reject(new Error('JSON parse error: ' + e.message));
}
}
};

View file

@ -1,13 +0,0 @@
package com.nuvio.tv.core.plugin
internal object PluginSafety {
fun isVideoEasyScraper(
scraperId: String?,
scraperName: String? = null,
filename: String? = null
): Boolean {
return listOf(scraperId, scraperName, filename).any { value ->
value?.contains("videasy", ignoreCase = true) == true
}
}
}

View file

@ -1,18 +0,0 @@
package com.nuvio.tv.core.plugin
import android.util.Log
private const val TAG = "TestDiagnostics"
/**
* Collects diagnostic steps during a scraper test run.
* Each step is a status line like "DEX loaded: 2 MainAPIs" or "Search: 5 results for 'The Matrix'".
*/
data class TestDiagnostics(
val steps: MutableList<String> = mutableListOf()
) {
fun addStep(step: String) {
steps.add(step)
Log.d(TAG, step)
}
}

View file

@ -1,761 +0,0 @@
package com.nuvio.tv.core.plugin.cloudstream
import android.content.Context
import android.os.Build
import android.util.Log
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.plugins.BasePlugin
import com.lagradost.cloudstream3.plugins.Plugin
import com.lagradost.cloudstream3.plugins.CloudstreamPlugin
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.extractorApis
import com.nuvio.tv.core.plugin.TestDiagnostics
import dalvik.system.DexClassLoader
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ExtExtensionLoader"
/**
* Checks whether an instance looks like a CloudStream plugin by checking if it has
* plugin-like methods. Covers three cases:
* 1. Our Plugin class: load(Activity?), load(Context), getRegisteredMainAPIs()
* 2. Library's BasePlugin: load() (no-arg), registerMainAPI()
* 3. Foreign plugins with similar signatures
*/
private fun looksLikePlugin(instance: Any): Boolean {
// Fast path: check if it's an instance of the library's BasePlugin
if (instance is BasePlugin) return true
val clazz = instance.javaClass
// Check for any form of load() method
val hasLoad = try {
clazz.getMethod("load", Context::class.java) != null
} catch (_: NoSuchMethodException) {
try {
clazz.getMethod("load", android.app.Activity::class.java) != null
} catch (_: NoSuchMethodException) {
try {
// BasePlugin-style no-arg load()
clazz.getMethod("load") != null
} catch (_: NoSuchMethodException) {
false
}
}
}
val hasRegisteredAPIs = try {
clazz.getMethod("getRegisteredMainAPIs") != null
} catch (_: NoSuchMethodException) {
false
}
return hasLoad || hasRegisteredAPIs
}
/**
* Wraps a plugin instance loaded from a foreign classloader or with a non-standard base class.
* Handles three plugin patterns:
* 1. Our Plugin: load(Activity?), load(Context), getRegisteredMainAPIs()
* 2. Library's BasePlugin: load() no-arg, registers to APIHolder.allProviders + extractorApis
* 3. Foreign plugins with similar signatures
*/
private class ReflectivePluginWrapper(private val foreignInstance: Any) : Plugin() {
override fun load(activity: android.app.Activity?) {
load(activity as? Context ?: AcraApplication.context ?: return)
}
override fun load(context: Context) {
// Snapshot global registries before load() to detect what this plugin adds
val providersBefore = synchronized(APIHolder.allProviders) {
APIHolder.allProviders.toList()
}
val extractorsBefore = extractorApis.toList()
val clazz = foreignInstance.javaClass
var loaded = false
// Try load(Context) first
try {
val m = clazz.getMethod("load", Context::class.java)
m.invoke(foreignInstance, context)
loaded = true
} catch (e: java.lang.reflect.InvocationTargetException) {
val cause = e.cause
if (cause is ClassCastException) {
Log.d(TAG, "ReflectivePluginWrapper: load(Context) got ClassCastException, retrying with null Activity")
try {
val m = clazz.getMethod("load", android.app.Activity::class.java)
m.invoke(foreignInstance, null)
loaded = true
} catch (e2: Exception) {
Log.w(TAG, "ReflectivePluginWrapper: load(Activity) also failed: ${e2.message}")
}
} else {
Log.w(TAG, "ReflectivePluginWrapper: load(Context) threw: ${cause?.message ?: e.message}")
}
} catch (_: NoSuchMethodException) {
// Try load(Activity?) next
try {
val m = clazz.getMethod("load", android.app.Activity::class.java)
m.invoke(foreignInstance, null)
loaded = true
} catch (_: NoSuchMethodException) {
// Try no-arg load() (BasePlugin pattern)
try {
val m = clazz.getMethod("load")
m.invoke(foreignInstance)
loaded = true
Log.d(TAG, "ReflectivePluginWrapper: loaded via no-arg load()")
} catch (e: Exception) {
Log.w(TAG, "ReflectivePluginWrapper: load() (no-arg) failed: ${e.message}")
}
} catch (e: Exception) {
Log.w(TAG, "ReflectivePluginWrapper: load(Activity) failed: ${e.message}")
}
} catch (e: Exception) {
Log.w(TAG, "ReflectivePluginWrapper: load() failed: ${e.message}")
}
// First try reading local registration lists (our Plugin pattern)
try {
val getter = clazz.getMethod("getRegisteredMainAPIs")
@Suppress("UNCHECKED_CAST")
val apis = getter.invoke(foreignInstance) as? List<MainAPI> ?: emptyList()
apis.forEach { registerMainAPI(it) }
} catch (_: Exception) {
// No local list — check global APIHolder for newly registered providers
// (BasePlugin.registerMainAPI adds to APIHolder.allProviders)
val newProviders = synchronized(APIHolder.allProviders) {
APIHolder.allProviders.toList()
} - providersBefore.toSet()
if (newProviders.isNotEmpty()) {
Log.d(TAG, "ReflectivePluginWrapper: found ${newProviders.size} providers via APIHolder")
newProviders.forEach { registerMainAPI(it) }
}
}
try {
val getter = clazz.getMethod("getRegisteredExtractorAPIs")
@Suppress("UNCHECKED_CAST")
val extractors = getter.invoke(foreignInstance) as? List<ExtractorApi> ?: emptyList()
extractors.forEach { registerExtractorAPI(it) }
} catch (_: Exception) {
// Check global extractorApis for newly registered extractors
val newExtractors = extractorApis.toList() - extractorsBefore.toSet()
if (newExtractors.isNotEmpty()) {
Log.d(TAG, "ReflectivePluginWrapper: found ${newExtractors.size} extractors via extractorApis")
newExtractors.forEach { registerExtractorAPI(it) }
}
}
}
}
private const val MAX_DEX_SIZE = 10 * 1024 * 1024L // 10MB max per .cs3 file
/**
* Manages downloading, loading, and caching of DEX-based external extensions (.cs3 files).
*/
@Singleton
class ExternalExtensionLoader @Inject constructor(
@ApplicationContext private val context: Context,
private val extractorRegistry: ExternalExtractorRegistry
) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.followRedirects(true)
.build()
/** Cache of loaded MainAPI instances by scraper ID */
private val apiCache = ConcurrentHashMap<String, MainAPI>()
/** Cache of loaded class loaders by scraper ID */
private val classLoaderCache = ConcurrentHashMap<String, DexClassLoader>()
/** Tracks which scraper IDs have already been scanned for extractors */
private val extractorPreloadedIds = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val extensionsDir: File
get() = File(context.filesDir, "cs_extensions").also { it.mkdirs() }
private val codeCacheDir: File
get() = File(context.codeCacheDir, "cs_dex_cache").also { it.mkdirs() }
/** Sanitize scraper ID for use as a filename (colons are not safe on all filesystems). */
private fun safeFileName(scraperId: String): String =
scraperId.replace(':', '_').replace('/', '_')
/**
* Download a .cs3 DEX file for the given scraper.
* Returns the local file path, or null on failure.
*/
suspend fun downloadExtension(scraperId: String, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try {
val targetFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3")
// Remove existing read-only file before writing (DEX files are set
// read-only for API 28+ compat, so overwriting would fail with EACCES)
if (targetFile.exists()) {
targetFile.setWritable(true)
targetFile.delete()
}
val request = Request.Builder()
.url(downloadUrl)
.header("User-Agent", "NuvioTV/1.0")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.e(TAG, "Failed to download extension $scraperId: HTTP ${response.code}")
return@withContext null
}
val contentLength = response.header("Content-Length")?.toLongOrNull() ?: 0
if (contentLength > MAX_DEX_SIZE) {
Log.w(TAG, "Extension $scraperId too large: $contentLength bytes")
return@withContext null
}
val bytes = response.body?.bytes() ?: return@withContext null
if (bytes.size > MAX_DEX_SIZE) {
Log.w(TAG, "Extension $scraperId too large: ${bytes.size} bytes")
return@withContext null
}
targetFile.writeBytes(bytes)
// Fix for Android API 28+: DEX files must be read-only
// Writing writable DEX files is blocked on newer Android versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
targetFile.setReadOnly()
Log.d(TAG, "Set DEX file read-only for API ${Build.VERSION.SDK_INT}")
}
Log.d(TAG, "Downloaded extension $scraperId: ${bytes.size} bytes -> ${targetFile.absolutePath}")
targetFile
}
} catch (e: Exception) {
Log.e(TAG, "Error downloading extension $scraperId: ${e.message}", e)
null
}
}
/**
* Load a .cs3 DEX file and return the MainAPI instance(s) registered by the plugin.
*/
fun loadExtension(scraperId: String): List<MainAPI> {
// Check cache first
apiCache[scraperId]?.let { return listOf(it) }
val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3")
if (!dexFile.exists()) {
Log.e(TAG, "DEX file not found for $scraperId: ${dexFile.absolutePath}")
return emptyList()
}
// Ensure DEX file is read-only (fix for existing downloads on API 28+)
ensureDexReadOnly(dexFile)
return try {
val classLoader = DexClassLoader(
dexFile.absolutePath,
codeCacheDir.absolutePath,
null,
context.classLoader
)
classLoaderCache[scraperId] = classLoader
// Check for critical class shadowing
try {
@Suppress("DEPRECATION")
val inspectDex = dalvik.system.DexFile(dexFile)
val allEntries = inspectDex.entries().toList()
inspectDex.close()
val criticalShadows = allEntries.filter { className ->
className == "com.lagradost.cloudstream3.MainActivityKt" ||
className == "com.lagradost.cloudstream3.MainAPIKt" ||
className == "com.lagradost.cloudstream3.utils.ExtractorApiKt" ||
className == "com.lagradost.cloudstream3.utils.AppUtilsKt"
}
if (criticalShadows.isNotEmpty()) {
Log.w(TAG, "Extension $scraperId shadows critical classes: $criticalShadows")
}
} catch (_: Exception) {}
// Find and instantiate the plugin class
val plugin = findAndLoadPlugin(classLoader, dexFile)
if (plugin == null) {
Log.e(TAG, "No @CloudstreamPlugin class found in $scraperId")
return emptyList()
}
// Ensure global stubs are initialized for extensions
AcraApplication.context = context
extractorRegistry.installGlobal()
// Call load() to trigger registerMainAPI() calls.
val activity = AcraApplication.getActivity()
try {
if (activity != null) {
plugin.load(activity as android.app.Activity?)
} else {
plugin.load(context)
}
} catch (e: Exception) {
Log.w(TAG, "plugin.load() threw (partial load, ${plugin.registeredMainAPIs.size} APIs so far): ${e.message}", e)
} catch (e: Error) {
val missingClass = extractMissingClassName(e)
if (missingClass != null) {
Log.w(TAG, "plugin.load() MISSING CLASS: $missingClass (${plugin.registeredMainAPIs.size} APIs so far)", e)
} else {
Log.w(TAG, "plugin.load() linkage error (partial load, ${plugin.registeredMainAPIs.size} APIs so far): ${e.message}", e)
}
}
// Register any extractors the plugin provides
extractorRegistry.registerAll(plugin.registeredExtractorAPIs)
var apis = plugin.registeredMainAPIs
Log.d(TAG, "After load(): ${apis.size} MainAPIs, ${plugin.registeredExtractorAPIs.size} extractors")
// FALLBACK: If load() registered 0 APIs or 0 extractors, scan DEX directly.
if (apis.isEmpty() || plugin.registeredExtractorAPIs.isEmpty()) {
Log.d(TAG, "Fallback: scanning DEX for MainAPI/ExtractorApi subclasses in $scraperId")
val fallbackApis = mutableListOf<MainAPI>()
val fallbackExtractors = mutableListOf<ExtractorApi>()
try {
@Suppress("DEPRECATION")
val inspectDex = dalvik.system.DexFile(dexFile)
val allClasses = inspectDex.entries().toList()
inspectDex.close()
val candidates = allClasses.filter { className ->
!className.contains('$') &&
!className.contains("Plugin") &&
!className.contains("Fragment") &&
className.startsWith("com.") &&
!className.startsWith("com.lagradost.cloudstream3.")
}
for (className in candidates) {
try {
val clazz = classLoader.loadClass(className)
if (apis.isEmpty() && MainAPI::class.java.isAssignableFrom(clazz)) {
val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI
fallbackApis.add(instance)
Log.d(TAG, "Fallback found MainAPI: ${instance.name} ($className)")
} else if (ExtractorApi::class.java.isAssignableFrom(clazz)
&& !java.lang.reflect.Modifier.isAbstract(clazz.modifiers)) {
val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi
fallbackExtractors.add(instance)
Log.d(TAG, "Fallback found ExtractorApi: ${instance.name} (${instance.mainUrl})")
}
} catch (e: Error) {
val missing = extractMissingClassName(e)
if (missing != null) {
Log.w(TAG, "Fallback skip $className: MISSING $missing")
}
} catch (_: Exception) {
// Skip classes that can't be instantiated
}
}
} catch (e: Exception) {
Log.w(TAG, "Fallback DEX scan failed: ${e.message}")
}
if (fallbackApis.isNotEmpty()) {
Log.d(TAG, "Fallback found ${fallbackApis.size} MainAPIs")
apis = fallbackApis
}
if (fallbackExtractors.isNotEmpty()) {
Log.d(TAG, "Fallback found ${fallbackExtractors.size} ExtractorApis")
extractorRegistry.registerAll(fallbackExtractors)
}
}
apis.forEach { api ->
apiCache["$scraperId:${api.name}"] = api
}
// Also cache the first API under the plain scraper ID
if (apis.isNotEmpty()) {
apiCache[scraperId] = apis.first()
}
Log.d(TAG, "Loaded extension $scraperId: ${apis.size} providers (${apis.joinToString { it.name }})")
apis
} catch (e: Exception) {
Log.e(TAG, "Failed to load extension $scraperId: ${e.message}", e)
emptyList()
} catch (e: Error) {
val missingClass = extractMissingClassName(e)
if (missingClass != null) {
Log.e(TAG, "Failed to load extension $scraperId: MISSING CLASS: $missingClass (${e.javaClass.simpleName})", e)
} else {
Log.e(TAG, "Failed to load extension $scraperId (linkage error): ${e.message}", e)
}
emptyList()
}
}
/**
* Get a cached MainAPI for the given scraper ID, loading if necessary.
*/
fun getApi(scraperId: String): MainAPI? {
return apiCache[scraperId] ?: run {
val apis = loadExtension(scraperId)
apis.firstOrNull()
}
}
/**
* Load extension with diagnostic output.
*/
fun loadExtensionWithDiagnostics(scraperId: String, diagnostics: TestDiagnostics): List<MainAPI> {
apiCache[scraperId]?.let {
diagnostics.addStep("MainAPI cached: ${it.name}")
return listOf(it)
}
val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3")
if (!dexFile.exists()) {
diagnostics.addStep("DEX file NOT FOUND: ${dexFile.name}")
return emptyList()
}
diagnostics.addStep("DEX: ${dexFile.length()} bytes")
// Ensure read-only
ensureDexReadOnly(dexFile)
return try {
val classLoader = DexClassLoader(
dexFile.absolutePath,
codeCacheDir.absolutePath,
null,
context.classLoader
)
classLoaderCache[scraperId] = classLoader
val allClasses: List<String>
try {
@Suppress("DEPRECATION")
val inspectDex = dalvik.system.DexFile(dexFile)
allClasses = inspectDex.entries().toList()
inspectDex.close()
} catch (e: Exception) {
diagnostics.addStep("DEX inspection failed: ${e.message?.take(100)}")
return emptyList()
}
val shadowsPlugin = allClasses.any { it == "com.lagradost.cloudstream3.plugins.Plugin" }
if (shadowsPlugin) {
diagnostics.addStep("WARNING: DEX contains its own Plugin class!")
}
val plugin = findAndLoadPlugin(classLoader, dexFile)
if (plugin == null) {
diagnostics.addStep("No @CloudstreamPlugin found in DEX")
return emptyList()
}
val sameClass = plugin.javaClass.superclass == Plugin::class.java
diagnostics.addStep("Plugin: ${plugin.javaClass.simpleName}, sameBaseClass=$sameClass, isWrapper=${plugin is ReflectivePluginWrapper}")
AcraApplication.context = context
extractorRegistry.installGlobal()
val activity = AcraApplication.getActivity()
try {
if (activity != null) {
plugin.load(activity as android.app.Activity?)
} else {
plugin.load(context)
}
diagnostics.addStep("load(${if (activity != null) "Activity" else "Context"}): OK, ${plugin.registeredMainAPIs.size} APIs")
} catch (e: Exception) {
diagnostics.addStep("load() FAILED: ${e.javaClass.simpleName}: ${e.message?.take(120)}")
} catch (e: Error) {
val missing = extractMissingClassName(e)
diagnostics.addStep("load() ERROR: ${missing ?: e.message?.take(120)}")
}
extractorRegistry.registerAll(plugin.registeredExtractorAPIs)
var apis = plugin.registeredMainAPIs
if (apis.isEmpty() || plugin.registeredExtractorAPIs.isEmpty()) {
diagnostics.addStep("Fallback: scanning DEX for MainAPI + ExtractorApi subclasses...")
val fallbackApis = mutableListOf<MainAPI>()
val fallbackExtractors = mutableListOf<ExtractorApi>()
val candidates = allClasses.filter { className ->
!className.contains('$') &&
!className.contains("Plugin") &&
!className.contains("Fragment") &&
className.startsWith("com.") &&
!className.startsWith("com.lagradost.cloudstream3.")
}
for (className in candidates) {
try {
val clazz = classLoader.loadClass(className)
if (apis.isEmpty() && MainAPI::class.java.isAssignableFrom(clazz)) {
val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI
fallbackApis.add(instance)
diagnostics.addStep("Found API: ${instance.name} (${clazz.simpleName})")
} else if (ExtractorApi::class.java.isAssignableFrom(clazz)
&& !java.lang.reflect.Modifier.isAbstract(clazz.modifiers)) {
val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi
fallbackExtractors.add(instance)
diagnostics.addStep("Found Extractor: ${instance.name} (${instance.mainUrl})")
}
} catch (e: Error) {
val missing = extractMissingClassName(e)
if (missing != null) {
diagnostics.addStep("${className.substringAfterLast('.')}: MISSING $missing")
}
} catch (e: Exception) {
val cause = e.cause ?: e
if (cause is Error) {
val missing = extractMissingClassName(cause as Error)
if (missing != null) {
diagnostics.addStep("${className.substringAfterLast('.')}: MISSING $missing")
}
}
}
}
if (fallbackApis.isNotEmpty()) {
diagnostics.addStep("Fallback found ${fallbackApis.size} APIs")
apis = fallbackApis
}
if (fallbackExtractors.isNotEmpty()) {
diagnostics.addStep("Fallback found ${fallbackExtractors.size} extractors")
extractorRegistry.registerAll(fallbackExtractors)
}
}
apis.forEach { api ->
apiCache["$scraperId:${api.name}"] = api
}
if (apis.isNotEmpty()) {
apiCache[scraperId] = apis.first()
}
apis
} catch (e: Exception) {
diagnostics.addStep("FAILED: ${e.javaClass.simpleName}: ${e.message?.take(200)}")
emptyList()
} catch (e: Error) {
val missing = extractMissingClassName(e)
diagnostics.addStep("FAILED: ${missing ?: e.message?.take(200)}")
emptyList()
}
}
/**
* Eagerly load all ExtractorApi subclasses from the given .cs3 files.
*/
fun ensureExtractorsLoaded(scraperIds: List<String>, diagnostics: TestDiagnostics? = null) {
val idsToLoad = scraperIds.filter { it !in extractorPreloadedIds }
if (idsToLoad.isEmpty()) {
diagnostics?.addStep("Extractors: all ${scraperIds.size} already preloaded")
return
}
AcraApplication.context = context
extractorRegistry.installGlobal()
var totalExtractors = 0
var totalScanned = 0
for (scraperId in idsToLoad) {
extractorPreloadedIds.add(scraperId)
val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3")
if (!dexFile.exists()) continue
// Ensure read-only
ensureDexReadOnly(dexFile)
totalScanned++
try {
val classLoader = classLoaderCache.getOrPut(scraperId) {
DexClassLoader(
dexFile.absolutePath,
codeCacheDir.absolutePath,
null,
context.classLoader
)
}
val plugin = findAndLoadPlugin(classLoader, dexFile)
if (plugin != null) {
val activity = AcraApplication.getActivity()
try {
if (activity != null) {
plugin.load(activity as android.app.Activity?)
} else {
plugin.load(context)
}
} catch (_: Exception) {
} catch (_: Error) {
}
if (plugin.registeredExtractorAPIs.isNotEmpty()) {
extractorRegistry.registerAll(plugin.registeredExtractorAPIs)
totalExtractors += plugin.registeredExtractorAPIs.size
continue
}
}
@Suppress("DEPRECATION")
val inspectDex = dalvik.system.DexFile(dexFile)
val allClasses = inspectDex.entries().toList()
inspectDex.close()
for (className in allClasses) {
if (className.contains('$')) continue
try {
val clazz = classLoader.loadClass(className)
if (ExtractorApi::class.java.isAssignableFrom(clazz)
&& !java.lang.reflect.Modifier.isAbstract(clazz.modifiers)
) {
val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi
extractorRegistry.registerExtractor(instance)
totalExtractors++
}
} catch (_: Exception) {
} catch (_: Error) {
}
}
} catch (e: Exception) {
Log.w(TAG, "ensureExtractorsLoaded: failed for $scraperId: ${e.message}")
} catch (e: Error) {
Log.w(TAG, "ensureExtractorsLoaded: linkage error for $scraperId: ${e.message}")
}
}
Log.d(TAG, "ensureExtractorsLoaded: scanned $totalScanned .cs3 files, registered $totalExtractors extractors")
diagnostics?.addStep("Preloaded $totalExtractors extractors from $totalScanned .cs3 files")
}
fun deleteExtension(scraperId: String) {
apiCache.keys.filter { it.startsWith(scraperId) }.forEach { apiCache.remove(it) }
classLoaderCache.remove(scraperId)
extractorPreloadedIds.remove(scraperId)
File(extensionsDir, "${safeFileName(scraperId)}.cs3").delete()
Log.d(TAG, "Deleted extension $scraperId")
}
fun evictCache(scraperId: String) {
apiCache.keys.filter { it.startsWith(scraperId) }.forEach { apiCache.remove(it) }
classLoaderCache.remove(scraperId)
extractorPreloadedIds.remove(scraperId)
}
/**
* Ensure a DEX file is read-only. Required for Android API 28+ which blocks
* writable DEX file loading.
*/
private fun ensureDexReadOnly(dexFile: File) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && dexFile.canWrite()) {
dexFile.setReadOnly()
Log.d(TAG, "Fixed DEX permissions (set read-only): ${dexFile.name}")
}
}
private fun extractMissingClassName(e: Error): String? {
val msg = e.message ?: return null
val match = Regex("""(?:L?)([\w/.]+)(?:;)?""").find(msg)
return match?.groupValues?.get(1)?.replace('/', '.')
}
private fun findAndLoadPlugin(classLoader: DexClassLoader, cs3File: File): Plugin? {
val pluginClassName = readPluginClassNameFromZip(cs3File)
if (pluginClassName != null) {
try {
Log.d(TAG, "Loading plugin class from manifest: $pluginClassName")
val clazz = classLoader.loadClass(pluginClassName)
val instance = clazz.getDeclaredConstructor().newInstance()
if (instance is Plugin) {
return instance
}
if (looksLikePlugin(instance)) {
Log.d(TAG, "Using reflective wrapper for $pluginClassName (non-standard base class)")
return ReflectivePluginWrapper(instance)
}
Log.w(TAG, "Class $pluginClassName is not a Plugin and has no plugin methods")
} catch (e: Exception) {
Log.e(TAG, "Failed to load manifest class $pluginClassName: ${e.message}", e)
} catch (e: Error) {
Log.e(TAG, "Linkage error loading manifest class $pluginClassName: ${e.message}", e)
}
}
return scanForPluginClass(classLoader, cs3File)
}
private fun readPluginClassNameFromZip(cs3File: File): String? {
return try {
ZipFile(cs3File).use { zip ->
val manifestEntry = zip.getEntry("manifest.json") ?: return null
val json = zip.getInputStream(manifestEntry).bufferedReader().readText()
val obj = JSONObject(json)
obj.optString("pluginClassName", null)
}
} catch (e: Exception) {
Log.d(TAG, "Could not read manifest.json from ZIP: ${e.message}")
null
}
}
private fun scanForPluginClass(classLoader: DexClassLoader, cs3File: File): Plugin? {
try {
@Suppress("DEPRECATION")
val dex = dalvik.system.DexFile(cs3File)
val entries = dex.entries()
while (entries.hasMoreElements()) {
val className = entries.nextElement()
try {
val clazz = classLoader.loadClass(className)
if (clazz.isAnnotationPresent(CloudstreamPlugin::class.java)) {
Log.d(TAG, "Found plugin class via scan: $className")
val instance = clazz.getDeclaredConstructor().newInstance()
if (instance is Plugin) {
dex.close()
return instance
}
if (looksLikePlugin(instance)) {
Log.d(TAG, "Using reflective wrapper for $className (non-standard base class)")
dex.close()
return ReflectivePluginWrapper(instance)
}
Log.w(TAG, "Annotated class $className has no plugin methods")
}
} catch (_: ClassNotFoundException) {
} catch (_: NoClassDefFoundError) {
} catch (e: Exception) {
Log.w(TAG, "Error inspecting class $className: ${e.message}")
}
}
dex.close()
} catch (e: Exception) {
Log.d(TAG, "DexFile scan fallback failed: ${e.message}")
}
return null
}
}

View file

@ -1,715 +0,0 @@
package com.nuvio.tv.core.plugin.cloudstream
import android.util.Log
import com.lagradost.cloudstream3.AnimeLoadResponse
import com.lagradost.cloudstream3.Episode
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.LiveStreamLoadResponse
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MovieLoadResponse
import com.lagradost.cloudstream3.MovieSearchResponse
import com.lagradost.cloudstream3.TvSeriesSearchResponse
import com.lagradost.cloudstream3.AnimeSearchResponse
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.TvSeriesLoadResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.metaproviders.TmdbLink
import com.lagradost.cloudstream3.metaproviders.TmdbProvider
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.app
import com.nuvio.tv.core.plugin.TestDiagnostics
import com.nuvio.tv.core.tmdb.TmdbMetadataService
import com.nuvio.tv.core.tmdb.TmdbService
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.LocalScraperResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ExtExtensionRunner"
private const val EXECUTION_TIMEOUT_MS = 120_000L
private const val MIN_TITLE_SIMILARITY = 0.5
/**
* Executes external DEX extensions by bridging between NuvioTV's TMDB ID-based system
* and the extensions' text search-based API.
*
* Flow: TMDB ID title lookup search() match load() loadLinks() LocalScraperResult
*/
@Singleton
class ExternalExtensionRunner @Inject constructor(
private val extensionLoader: ExternalExtensionLoader,
private val extractorRegistry: ExternalExtractorRegistry,
private val tmdbMetadataService: TmdbMetadataService,
private val tmdbService: TmdbService
) {
suspend fun execute(
scraperId: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> = withContext(Dispatchers.IO) {
extensionLoader.ensureExtractorsLoaded(listOf(scraperId))
val api = extensionLoader.getApi(scraperId)
if (api == null) {
Log.e(TAG, "No API loaded for scraper: $scraperId")
return@withContext emptyList()
}
try {
executeInternal(api, tmdbId, mediaType, season, episode)
} catch (e: Exception) {
Log.e(TAG, "Extension ${api.name} failed: ${e.javaClass.simpleName}: ${e.message}", e)
emptyList()
} catch (e: Error) {
val missing = extractMissingClass(e)
if (missing != null) {
Log.e(TAG, "Extension ${api.name} MISSING CLASS: $missing", e)
} else {
Log.e(TAG, "Extension ${api.name} linkage error: ${e.javaClass.simpleName}: ${e.message}", e)
}
emptyList()
}
}
suspend fun executeWithDiagnostics(
scraperId: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
diagnostics: TestDiagnostics
): List<LocalScraperResult> = withContext(Dispatchers.IO) {
extensionLoader.ensureExtractorsLoaded(listOf(scraperId), diagnostics)
diagnostics.addStep("Loading DEX extension...")
val apis = extensionLoader.loadExtensionWithDiagnostics(scraperId, diagnostics)
val api = apis.firstOrNull()
if (api == null) {
diagnostics.addStep("No MainAPI available after load")
return@withContext emptyList()
}
diagnostics.addStep("Using MainAPI: ${api.name} (${api.javaClass.simpleName})")
val isTmdb = api is TmdbProvider
diagnostics.addStep("Provider type: ${if (isTmdb) "TmdbProvider" else "search-based"}")
withTimeoutOrNull(EXECUTION_TIMEOUT_MS) {
try {
if (isTmdb) {
executeTmdbProviderWithDiagnostics(api, tmdbId, mediaType, season, episode, diagnostics)
} else {
executeSearchBasedWithDiagnostics(api, tmdbId, mediaType, season, episode, diagnostics)
}
} catch (e: Error) {
Log.e(TAG, "Diagnostic ${api.name} error: ${e.javaClass.simpleName}: ${e.message}", e)
diagnostics.addStep("Runtime error: ${e.javaClass.simpleName}")
diagnostics.addStep("Detail: ${e.message?.take(300)}")
emptyList()
} catch (e: Exception) {
Log.e(TAG, "Diagnostic ${api.name} exception: ${e.javaClass.simpleName}: ${e.message}", e)
diagnostics.addStep("Runtime exception: ${e.javaClass.simpleName}: ${e.message?.take(300)}")
emptyList()
}
} ?: run {
diagnostics.addStep("TIMEOUT after ${EXECUTION_TIMEOUT_MS}ms")
emptyList()
}
}
private suspend fun executeTmdbProviderWithDiagnostics(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
diagnostics: TestDiagnostics
): List<LocalScraperResult> {
val tmdbIdInt = tmdbId.toIntOrNull()
val contentType = when (mediaType.lowercase()) {
"movie" -> ContentType.MOVIE
else -> ContentType.SERIES
}
diagnostics.addStep("Fetching TMDB metadata...")
val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType)
val movieName = enrichment?.localizedTitle
diagnostics.addStep("TMDB title: ${movieName ?: "(null)"}")
val imdbId = if (tmdbIdInt != null) tmdbService.tmdbToImdb(tmdbIdInt, mediaType) else null
diagnostics.addStep("IMDB ID: ${imdbId ?: "(not found)"}")
val tmdbLink = TmdbLink(
imdbID = imdbId,
tmdbID = tmdbIdInt,
episode = episode,
season = season,
movieName = movieName
)
val data = tmdbLink.toJson()
diagnostics.addStep("TmdbLink JSON: ${data.take(120)}")
diagnostics.addStep("Calling loadLinks()...")
val links = mutableListOf<ExtractorLink>()
val subtitles = mutableListOf<SubtitleFile>()
// Instrument loadExtractor to log each call's result
data class ExtractorCall(val url: String, var matched: Boolean = false, var linkCount: Int = 0, var error: String? = null)
val extractorCalls = mutableListOf<ExtractorCall>()
val success = try {
api.loadLinks(
data = data,
isCasting = false,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
)
} catch (e: Throwable) {
diagnostics.addStep("loadLinks THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}")
false
}
diagnostics.addStep("loadLinks returned: success=$success, ${links.size} links, ${subtitles.size} subs")
// Show missing extractor domains
val missing = extractorRegistry.getMissingExtractorDomains()
if (missing.isNotEmpty()) {
diagnostics.addStep("Missing extractors: ${missing.take(5).joinToString()}")
}
return links.filterValid().map { it.toLocalScraperResult(api.name) }
}
private suspend fun executeSearchBasedWithDiagnostics(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
diagnostics: TestDiagnostics
): List<LocalScraperResult> {
val contentType = when (mediaType.lowercase()) {
"movie" -> ContentType.MOVIE
else -> ContentType.SERIES
}
diagnostics.addStep("Fetching TMDB metadata...")
val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType)
if (enrichment == null) {
diagnostics.addStep("TMDB enrichment FAILED")
return emptyList()
}
val title = enrichment.localizedTitle
if (title == null) {
diagnostics.addStep("TMDB returned no title")
return emptyList()
}
val year = enrichment.releaseInfo?.take(4)?.toIntOrNull()
diagnostics.addStep("TMDB: \"$title\" ($year)")
// Check if search() is actually overridden
val searchMethod = try {
api.javaClass.getMethod("search", String::class.java, kotlin.coroutines.Continuation::class.java)
} catch (_: Exception) { null }
val declaringClass = searchMethod?.declaringClass?.name ?: "unknown"
diagnostics.addStep("search() declared in: $declaringClass")
// Install temporary HTTP logging on the app singleton
val httpLog = mutableListOf<String>()
val originalClient = app.baseClient
val loggingClient = originalClient.newBuilder()
.addInterceptor { chain ->
val req = chain.request()
httpLog.add("${req.method} ${req.url}")
try {
val resp = chain.proceed(req)
httpLog.add("${resp.code} (${resp.body?.contentLength() ?: "?"} bytes)")
resp
} catch (e: Exception) {
httpLog.add("← FAILED: ${e.javaClass.simpleName}: ${e.message?.take(80)}")
throw e
}
}
.build()
app.baseClient = loggingClient
diagnostics.addStep("Searching for: \"$title\"")
var searchResults = try {
api.search(title, 1)?.items
} catch (e: Exception) {
diagnostics.addStep("search() THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}")
null
} catch (e: Error) {
val missingCls = extractMissingClass(e)
diagnostics.addStep("search() ERROR: ${missingCls ?: e.message?.take(120)}")
null
} finally {
app.baseClient = originalClient
}
// Show HTTP activity
if (httpLog.isEmpty()) {
diagnostics.addStep("HTTP: no requests made by search()")
} else {
diagnostics.addStep("HTTP: ${httpLog.size / 2} request(s)")
httpLog.take(6).forEach { diagnostics.addStep(" $it") }
if (httpLog.size > 6) diagnostics.addStep(" ... and ${httpLog.size - 6} more")
}
diagnostics.addStep("Search returned: ${if (searchResults == null) "null" else "${searchResults.size} results"}")
// Fallback: if title has special characters, try simplified version
if (searchResults.isNullOrEmpty() && title.contains(Regex("[:\\-–—]"))) {
val simplified = title.replace(Regex("[:\\-–—]"), " ").replace(Regex("\\s+"), " ").trim()
diagnostics.addStep("Retrying with: \"$simplified\"")
searchResults = try {
api.search(simplified, 1)?.items
} catch (e: Exception) {
diagnostics.addStep("search(simplified) THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}")
null
} catch (e: Error) {
null
}
diagnostics.addStep("Retry returned: ${if (searchResults == null) "null" else "${searchResults.size} results"}")
}
if (searchResults.isNullOrEmpty()) return emptyList()
val bestMatch = findBestMatch(searchResults, title, year, mediaType)
if (bestMatch == null) {
diagnostics.addStep("No match above similarity threshold ($MIN_TITLE_SIMILARITY)")
searchResults.take(3).forEachIndexed { i, r ->
val sim = calculateSimilarity(r.name, title)
diagnostics.addStep(" [$i] \"${r.name}\" (sim=${String.format("%.2f", sim)})")
}
return emptyList()
}
diagnostics.addStep("Best match: \"${bestMatch.name}\" (${bestMatch.url.take(80)})")
diagnostics.addStep("Loading page...")
val loadResponse = api.load(bestMatch.url)
if (loadResponse == null) {
diagnostics.addStep("load() returned null")
return emptyList()
}
diagnostics.addStep("Loaded: ${loadResponse.javaClass.simpleName}")
val data = extractData(loadResponse, mediaType, season, episode)
if (data == null) {
diagnostics.addStep("No episode data for S${season}E${episode}")
return emptyList()
}
diagnostics.addStep("Calling loadLinks()...")
val links = mutableListOf<ExtractorLink>()
val subtitles = mutableListOf<SubtitleFile>()
val success = api.loadLinks(
data = data,
isCasting = false,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
)
diagnostics.addStep("loadLinks returned: success=$success, ${links.size} links, ${subtitles.size} subs")
return links.filterValid().map { it.toLocalScraperResult(api.name) }
}
private fun extractMissingClass(e: Error): String? {
val msg = e.message ?: return null
val match = Regex("""(?:L?)([\w/.]+)(?:;)?""").find(msg)
return match?.groupValues?.get(1)?.replace('/', '.')
}
private suspend fun executeInternal(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
if (api is TmdbProvider) {
return executeTmdbProvider(api, tmdbId, mediaType, season, episode)
}
return executeSearchBased(api, tmdbId, mediaType, season, episode)
}
/**
* Execute a TmdbProvider extension using the same flow as CloudStream:
* 1. Construct the JSON that the extension's load() expects
* 2. Call api.load(json) extension fetches metadata, constructs its internal LinkData
* 3. Extract the data string from the LoadResponse
* 4. Call api.loadLinks(data, ...) extension resolves streams
*
* TmdbProvider extensions (StreamPlay, Ultima, etc.) override load() to accept
* JSON like {"id":803796,"type":"movie"}, NOT a TMDB URL. They then construct
* their own internal data classes (LinkData) with fields like "id", "imdbId",
* "title" etc. that differ from TmdbLink's field names.
*/
private suspend fun executeTmdbProvider(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
val tmdbIdInt = tmdbId.toIntOrNull()
val isMovie = mediaType.lowercase() == "movie"
val type = if (isMovie) "movie" else "tv"
// Construct the JSON that TmdbProvider extensions expect in load()
// This matches what their search() returns as URLs
val loadJson = """{"id":$tmdbIdInt,"type":"$type"}"""
Log.d(TAG, "TmdbProvider ${api.name}: load($loadJson)")
val loadResponse = try {
api.load(loadJson)
} catch (e: Exception) {
Log.w(TAG, "TmdbProvider ${api.name} load(json) threw: ${e.javaClass.simpleName}: ${e.message?.take(100)}")
null
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.w(TAG, "TmdbProvider ${api.name} load(json) error: ${missing ?: e.message?.take(100)}")
null
}
if (loadResponse != null) {
Log.d(TAG, "TmdbProvider ${api.name}: loaded ${loadResponse.javaClass.simpleName}")
val data = extractData(loadResponse, mediaType, season, episode)
if (data != null) {
Log.d(TAG, "TmdbProvider ${api.name}: loadLinks data=${data.take(200)}")
return executeTmdbLoadLinks(api, data)
}
Log.w(TAG, "TmdbProvider ${api.name}: no data for S${season}E${episode}")
}
// Fallback: try with TMDB URL format (standard TmdbProvider.load())
val tmdbUrl = if (isMovie) {
"https://www.themoviedb.org/movie/$tmdbId"
} else {
"https://www.themoviedb.org/tv/$tmdbId"
}
Log.d(TAG, "TmdbProvider ${api.name}: fallback load($tmdbUrl)")
val fallbackResponse = try {
api.load(tmdbUrl)
} catch (e: Exception) {
Log.w(TAG, "TmdbProvider ${api.name} fallback load(url) threw: ${e.javaClass.simpleName}: ${e.message?.take(100)}")
null
} catch (e: Error) { null }
if (fallbackResponse != null) {
val data = extractData(fallbackResponse, mediaType, season, episode)
if (data != null) {
Log.d(TAG, "TmdbProvider ${api.name}: fallback loadLinks data=${data.take(200)}")
return executeTmdbLoadLinks(api, data)
}
}
Log.w(TAG, "TmdbProvider ${api.name}: both load() paths failed")
return emptyList()
}
private suspend fun executeTmdbLoadLinks(
api: MainAPI,
data: String
): List<LocalScraperResult> {
// Use thread-safe list so links collected during loadLinks survive timeout
val links = java.util.Collections.synchronizedList(mutableListOf<ExtractorLink>())
val subtitles = java.util.Collections.synchronizedList(mutableListOf<SubtitleFile>())
try {
api.loadLinks(
data = data,
isCasting = false,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
)
} catch (e: Exception) {
// TimeoutCancellationException or other — links collected so far are still valid
Log.w(TAG, "TmdbProvider ${api.name} loadLinks ended: ${e.javaClass.simpleName} (${links.size} links collected)")
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.w(TAG, "TmdbProvider ${api.name} loadLinks error: ${missing ?: e.message} (${links.size} links collected)")
}
if (links.isEmpty()) {
Log.w(TAG, "TmdbProvider ${api.name}: 0 links collected")
return emptyList()
}
Log.d(TAG, "TmdbProvider ${api.name}: ${links.size} links, ${subtitles.size} subs")
return links.filterValid().map { link -> link.toLocalScraperResult(api.name) }
}
private suspend fun executeSearchBased(
api: MainAPI,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): List<LocalScraperResult> {
val contentType = when (mediaType.lowercase()) {
"movie" -> ContentType.MOVIE
else -> ContentType.SERIES
}
val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType)
if (enrichment == null) {
Log.e(TAG, "Failed to fetch TMDB enrichment for $tmdbId")
return emptyList()
}
val title = enrichment.localizedTitle ?: return emptyList()
val year = enrichment.releaseInfo?.take(4)?.toIntOrNull()
Log.d(TAG, "SearchBased ${api.name}: searching for \"$title\"")
var searchResults = try {
api.search(title, 1)?.items
} catch (e: Exception) {
Log.e(TAG, "SearchBased ${api.name} search() threw: ${e.javaClass.simpleName}: ${e.message}", e)
null
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.e(TAG, "SearchBased ${api.name} search() error: ${missing ?: e.message}", e)
null
}
if (searchResults.isNullOrEmpty() && title.contains(Regex("[:\\-–—]"))) {
val simplified = title.replace(Regex("[:\\-–—]"), " ").replace(Regex("\\s+"), " ").trim()
Log.d(TAG, "SearchBased ${api.name}: retrying with simplified \"$simplified\"")
searchResults = try {
api.search(simplified, 1)?.items
} catch (e: Exception) {
Log.e(TAG, "SearchBased ${api.name} search(simplified) threw: ${e.javaClass.simpleName}: ${e.message}", e)
null
} catch (e: Error) {
null
}
}
if (searchResults.isNullOrEmpty()) {
Log.w(TAG, "SearchBased ${api.name}: 0 search results for \"$title\"")
return emptyList()
}
Log.d(TAG, "SearchBased ${api.name}: ${searchResults.size} results")
val bestMatch = findBestMatch(searchResults, title, year, mediaType)
if (bestMatch == null) {
Log.d(TAG, "No suitable match in ${api.name} results for: $title ($year)")
searchResults.take(5).forEachIndexed { i, r ->
val sim = calculateSimilarity(r.name, title)
Log.d(TAG, " [$i] \"${r.name}\" (sim=${String.format("%.2f", sim)}, type=${r.type})")
}
return emptyList()
}
Log.d(TAG, "Best match from ${api.name}: ${bestMatch.name} (${bestMatch.url})")
val loadResponse = try {
api.load(bestMatch.url)
} catch (e: Exception) {
Log.e(TAG, "SearchBased ${api.name} load() threw: ${e.javaClass.simpleName}: ${e.message}", e)
null
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.e(TAG, "SearchBased ${api.name} load() error: ${missing ?: e.message}", e)
null
}
if (loadResponse == null) {
Log.w(TAG, "SearchBased ${api.name}: load(${bestMatch.url}) returned null")
return emptyList()
}
Log.d(TAG, "SearchBased ${api.name}: loaded ${loadResponse.javaClass.simpleName}")
val data = extractData(loadResponse, mediaType, season, episode)
if (data == null) {
Log.d(TAG, "No data extracted from ${api.name} for S${season}E${episode}")
return emptyList()
}
val links = mutableListOf<ExtractorLink>()
val subtitles = mutableListOf<SubtitleFile>()
val success = try {
api.loadLinks(
data = data,
isCasting = false,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
)
} catch (e: Exception) {
Log.e(TAG, "SearchBased ${api.name} loadLinks threw: ${e.javaClass.simpleName}: ${e.message}", e)
false
} catch (e: Error) {
val missing = extractMissingClass(e)
Log.e(TAG, "SearchBased ${api.name} loadLinks error: ${missing ?: e.message}", e)
false
}
if (!success && links.isEmpty()) {
Log.w(TAG, "SearchBased ${api.name}: loadLinks returned false, 0 links")
return emptyList()
}
Log.d(TAG, "SearchBased ${api.name}: ${links.size} links, ${subtitles.size} subs")
return links.filterValid().map { link -> link.toLocalScraperResult(api.name) }
}
/** Extract year from SearchResponse concrete types (not in the interface). */
private fun getSearchResponseYear(result: SearchResponse): Int? = when (result) {
is MovieSearchResponse -> result.year
is TvSeriesSearchResponse -> result.year
is AnimeSearchResponse -> result.year
else -> null
}
private fun findBestMatch(
results: List<SearchResponse>,
targetTitle: String,
targetYear: Int?,
mediaType: String
): SearchResponse? {
val isMovie = mediaType.lowercase() == "movie"
return results
.map { result ->
val titleSimilarity = calculateSimilarity(result.name, targetTitle)
val typeBonus = when {
result.type == null -> 0.0
isMovie && result.type in listOf(TvType.Movie, TvType.AnimeMovie, TvType.Documentary) -> 0.1
!isMovie && result.type in listOf(TvType.TvSeries, TvType.Anime, TvType.OVA, TvType.Cartoon, TvType.AsianDrama) -> 0.1
else -> -0.1
}
val resultYear = getSearchResponseYear(result)
val yearBonus = if (targetYear != null && resultYear == targetYear) 0.1 else 0.0
val score = titleSimilarity + typeBonus + yearBonus
result to score
}
.filter { it.second >= MIN_TITLE_SIMILARITY }
.maxByOrNull { it.second }
?.first
}
private fun extractData(
response: LoadResponse,
mediaType: String,
season: Int?,
episode: Int?
): String? = when (response) {
is MovieLoadResponse -> response.dataUrl
is LiveStreamLoadResponse -> response.dataUrl
is TvSeriesLoadResponse -> {
findEpisode(response.episodes, season, episode)?.data
}
is AnimeLoadResponse -> {
val allEpisodes = response.episodes.values.flatten()
findEpisode(allEpisodes, season, episode)?.data
}
else -> null
}
private fun findEpisode(episodes: List<Episode>, season: Int?, episode: Int?): Episode? {
if (episodes.isEmpty()) return null
if (season != null && episode != null) {
episodes.firstOrNull { it.season == season && it.episode == episode }?.let { return it }
}
if (episode != null) {
episodes.firstOrNull { it.episode == episode && (it.season == null || it.season == season) }
?.let { return it }
}
if (season != null && episode != null) {
val absoluteEpisode = episodes.indexOfFirst {
(it.season == season || it.season == null) && it.episode == episode
}
if (absoluteEpisode >= 0) return episodes[absoluteEpisode]
}
return null
}
private fun calculateSimilarity(s1: String, s2: String): Double {
val a = s1.lowercase().trim()
val b = s2.lowercase().trim()
if (a == b) return 1.0
if (a.isEmpty() || b.isEmpty()) return 0.0
val aNorm = a.replace(Regex("\\(\\d{4}\\)"), "").trim()
val bNorm = b.replace(Regex("\\(\\d{4}\\)"), "").trim()
if (aNorm == bNorm) return 0.95
if (aNorm.contains(bNorm) || bNorm.contains(aNorm)) return 0.85
val distance = levenshteinDistance(aNorm, bNorm)
val maxLen = maxOf(aNorm.length, bNorm.length)
return 1.0 - (distance.toDouble() / maxLen)
}
private fun levenshteinDistance(s1: String, s2: String): Int {
val m = s1.length
val n = s2.length
val dp = Array(m + 1) { IntArray(n + 1) }
for (i in 0..m) dp[i][0] = i
for (j in 0..n) dp[0][j] = j
for (i in 1..m) {
for (j in 1..n) {
val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1
dp[i][j] = minOf(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
)
}
}
return dp[m][n]
}
/** Filter out broken ExtractorLinks (invalid URLs, error strings, etc.) */
private fun List<ExtractorLink>.filterValid(): List<ExtractorLink> {
return filter { link ->
val url = link.url
when {
url.isBlank() -> false
url == "error" || url == "null" -> false
!url.startsWith("http://") && !url.startsWith("https://") -> false
else -> true
}.also { valid ->
if (!valid) Log.w(TAG, "Filtered invalid link: source=${link.source}, url=${url.take(60)}")
}
}
}
private fun ExtractorLink.toLocalScraperResult(providerName: String): LocalScraperResult {
val qualityStr = Qualities.getStringByInt(quality).ifEmpty { null }
val streamType = when (type) {
ExtractorLinkType.M3U8 -> "hls"
ExtractorLinkType.DASH -> "dash"
else -> null
}
val allHeaders = buildMap {
putAll(headers)
if (referer.isNotBlank()) put("Referer", referer)
}
return LocalScraperResult(
title = name,
name = source,
url = url,
quality = qualityStr,
type = streamType,
headers = allHeaders.ifEmpty { null },
provider = providerName
)
}
}

View file

@ -1,85 +0,0 @@
package com.nuvio.tv.core.plugin.cloudstream
import android.util.Log
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.loadExtractor
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ExtExtractorRegistry"
/**
* Registry of loaded extractors from external extensions.
* Bridges NuvioTV's extractor management with the CloudStream library's
* global [extractorApis] list and [loadExtractor] function.
*/
@Singleton
class ExternalExtractorRegistry @Inject constructor() {
private val missingExtractorDomains = mutableSetOf<String>()
private var installed = false
fun registerExtractor(extractor: ExtractorApi) {
// Avoid duplicates by mainUrl
if (extractorApis.any { it.mainUrl == extractor.mainUrl }) return
extractorApis.add(extractor)
Log.d(TAG, "Registered extractor: ${extractor.name} (${extractor.mainUrl})")
}
fun registerAll(extractorList: List<ExtractorApi>) {
extractorList.forEach { registerExtractor(it) }
}
fun clear() {
missingExtractorDomains.clear()
}
/**
* Try to resolve a URL using the library's loadExtractor.
* The library's loadExtractor iterates through the global extractorApis list
* which includes both built-in library extractors and extension-provided ones.
*/
suspend fun resolveExtractor(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
): Boolean {
return try {
val result = loadExtractor(url, referer, subtitleCallback, callback)
if (!result) {
val domain = try {
java.net.URI(url).host ?: url
} catch (_: Exception) {
url
}
if (missingExtractorDomains.add(domain)) {
Log.w(TAG, "No extractor registered for domain: $domain (url: $url)")
}
}
result
} catch (e: Exception) {
Log.e(TAG, "loadExtractor error for ${url.take(80)}: ${e.message}", e)
false
} catch (e: Error) {
Log.e(TAG, "loadExtractor linkage error for ${url.take(80)}: ${e.message}", e)
false
}
}
/**
* Install this registry. The library's loadExtractor function uses the global
* extractorApis list directly, so no delegate setup is needed.
* This method ensures the library's built-in extractors are available.
*/
fun installGlobal() {
if (installed) return
installed = true
Log.d(TAG, "installGlobal: library extractorApis has ${extractorApis.size} built-in extractors")
}
fun getMissingExtractorDomains(): Set<String> = missingExtractorDomains.toSet()
}

View file

@ -1,147 +0,0 @@
package com.nuvio.tv.core.plugin.cloudstream
import android.util.Log
import com.nuvio.tv.domain.model.ExternalPluginEntry
import com.nuvio.tv.domain.model.ExternalRepoManifest
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ExternalRepoParser"
/**
* Result of parsing an external repository URL.
*/
data class ExternalRepoParseResult(
val name: String,
val description: String?,
val plugins: List<ExternalPluginEntry>
)
/**
* Parses external extension repository formats.
*
* Supports two formats:
* 1. Repo manifest with `pluginLists` URLs pointing to separate plugins.json files
* 2. Direct plugins array (list of [ExternalPluginEntry])
*/
@Singleton
class ExternalRepoParser @Inject constructor(
private val moshi: Moshi
) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.followRedirects(true)
.build()
private val repoManifestAdapter = moshi.adapter(ExternalRepoManifest::class.java)
private val pluginListType = Types.newParameterizedType(List::class.java, ExternalPluginEntry::class.java)
private val pluginListAdapter = moshi.adapter<List<ExternalPluginEntry>>(pluginListType)
/**
* Try to parse the given URL as an external repository.
* Returns null if the content doesn't match any known external format.
*/
suspend fun tryParse(url: String): ExternalRepoParseResult? = withContext(Dispatchers.IO) {
val body = fetchBody(url) ?: return@withContext null
val trimmed = body.trim()
// Try as repo manifest (has "pluginLists" key)
if (trimmed.contains("\"pluginLists\"")) {
try {
val manifest = repoManifestAdapter.fromJson(trimmed)
if (manifest != null && manifest.pluginLists.isNotEmpty()) {
Log.d(TAG, "Parsed as repo manifest: ${manifest.name}, ${manifest.pluginLists.size} plugin lists")
val allPlugins = coroutineScope {
manifest.pluginLists.map { listUrl ->
async {
val resolvedUrl = resolveUrl(url, listUrl)
fetchPluginList(resolvedUrl) ?: emptyList()
}
}.awaitAll().flatten()
}
return@withContext ExternalRepoParseResult(
name = manifest.name,
description = manifest.description,
plugins = allPlugins
)
}
} catch (e: Exception) {
Log.d(TAG, "Not a repo manifest: ${e.message}")
}
}
// Try as direct plugins array (has "internalName" or "tvTypes")
if (trimmed.startsWith("[")) {
try {
val plugins = pluginListAdapter.fromJson(trimmed)
if (!plugins.isNullOrEmpty() && plugins.first().internalName.isNotBlank()) {
Log.d(TAG, "Parsed as direct plugins list: ${plugins.size} plugins")
val repoName = inferRepoName(url)
return@withContext ExternalRepoParseResult(
name = repoName,
description = null,
plugins = plugins
)
}
} catch (e: Exception) {
Log.d(TAG, "Not a direct plugins list: ${e.message}")
}
}
null
}
private suspend fun fetchPluginList(url: String): List<ExternalPluginEntry>? = withContext(Dispatchers.IO) {
val body = fetchBody(url) ?: return@withContext null
try {
pluginListAdapter.fromJson(body.trim())
} catch (e: Exception) {
Log.e(TAG, "Failed to parse plugin list from $url: ${e.message}")
null
}
}
private fun fetchBody(url: String): String? {
return try {
val request = Request.Builder()
.url(url)
.header("User-Agent", "NuvioTV/1.0")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.e(TAG, "HTTP ${response.code} for $url")
return null
}
response.body?.string()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch $url: ${e.message}")
null
}
}
private fun resolveUrl(baseUrl: String, relativeUrl: String): String {
if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://")) {
return relativeUrl
}
val base = baseUrl.substringBeforeLast("/")
return "$base/$relativeUrl"
}
private fun inferRepoName(url: String): String {
// Try to extract a meaningful name from the URL
val path = url.substringAfter("://").substringBefore("?")
val segments = path.split("/").filter { it.isNotBlank() }
return segments.lastOrNull()?.removeSuffix(".json") ?: "External Repository"
}
}

View file

@ -1,14 +0,0 @@
package com.nuvio.tv.core.plugin.cloudstream
import com.lagradost.cloudstream3.TvType
/** Map CloudStream TvType to NuvioTV content type string ("movie" or "tv"). */
fun TvType.toNuvioType(): String = when (this) {
TvType.Movie, TvType.AnimeMovie, TvType.Documentary, TvType.Torrent -> "movie"
else -> "tv"
}
/** Parse TvType from string name, case-insensitive. */
fun tvTypeFromString(value: String): TvType? = TvType.entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
}

View file

@ -48,8 +48,7 @@ class ProfileManager @Inject constructor(
name: String,
avatarColorHex: String,
usesPrimaryAddons: Boolean = false,
usesPrimaryPlugins: Boolean = false,
avatarId: String? = null
usesPrimaryPlugins: Boolean = false
): Boolean {
val current = profiles.value
if (current.size >= 4) return false
@ -62,10 +61,8 @@ class ProfileManager @Inject constructor(
name = name.trim().ifEmpty { "Profile $nextId" },
avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons,
usesPrimaryPlugins = usesPrimaryPlugins,
avatarId = avatarId
usesPrimaryPlugins = usesPrimaryPlugins
)
factory.markProfileCreated(nextId)
profileDataStore.upsertProfile(profile)
return true
}
@ -73,7 +70,7 @@ class ProfileManager @Inject constructor(
suspend fun deleteProfile(id: Int): Boolean {
if (id == 1) return false
if (profiles.value.none { it.id == id }) return false
deleteProfileDataAsync(id)
deleteProfileData(id)
profileDataStore.deleteProfile(id)
return true
}
@ -84,7 +81,7 @@ class ProfileManager @Inject constructor(
return true
}
private suspend fun deleteProfileDataAsync(profileId: Int) {
private fun deleteProfileData(profileId: Int) {
if (profileId == 1) return
factory.clearProfile(profileId)

View file

@ -1,6 +1,5 @@
package com.nuvio.tv.core.server
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD
@ -9,28 +8,12 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class AddonConfigServer(
private val context: Context,
private val webConfigMode: WebConfigMode,
private val currentPageStateProvider: () -> PageState,
private val onChangeProposed: (PendingAddonChange) -> Unit,
private val logoProvider: (() -> ByteArray?)? = null,
port: Int = 8080
) : NanoHTTPD(port) {
enum class WebConfigMode(
val allowAddonManagement: Boolean,
val allowCatalogManagement: Boolean
) {
FULL(
allowAddonManagement = true,
allowCatalogManagement = true
),
COLLECTIONS_ONLY(
allowAddonManagement = false,
allowCatalogManagement = false
)
}
data class AddonInfo(
val url: String,
val name: String,
@ -46,41 +29,9 @@ class AddonConfigServer(
val isDisabled: Boolean
)
data class CollectionInfo(
val id: String,
val title: String,
val backdropImageUrl: String? = null,
val pinToTop: Boolean = false,
val focusGlowEnabled: Boolean = true,
val viewMode: String = "TABBED_GRID",
val showAllTab: Boolean = true,
val folders: List<FolderInfo>
)
data class FolderInfo(
val id: String,
val title: String,
val coverImageUrl: String?,
val focusGifUrl: String?,
val focusGifEnabled: Boolean = true,
val coverEmoji: String?,
val tileShape: String,
val hideTitle: Boolean,
val catalogSources: List<CatalogSourceInfo>
)
data class CatalogSourceInfo(
val addonId: String,
val type: String,
val catalogId: String,
val genre: String? = null
)
data class PageState(
val addons: List<AddonInfo>,
val catalogs: List<CatalogInfo>,
val collections: List<CollectionInfo> = emptyList(),
val disabledCollectionKeys: List<String> = emptyList()
val catalogs: List<CatalogInfo>
)
data class PendingAddonChange(
@ -88,8 +39,6 @@ class AddonConfigServer(
val proposedUrls: List<String>,
val proposedCatalogOrderKeys: List<String> = emptyList(),
val proposedDisabledCatalogKeys: List<String> = emptyList(),
val proposedCollectionsJson: String? = null,
val proposedDisabledCollectionKeys: List<String> = emptyList(),
var status: ChangeStatus = ChangeStatus.PENDING
)
@ -116,18 +65,13 @@ class AddonConfigServer(
method == Method.GET && uri == "/api/state" -> servePageState()
method == Method.GET && uri == "/api/addons" -> serveAddonList()
method == Method.POST && uri == "/api/addons" -> handleAddonUpdate(session)
method == Method.GET && uri == "/api/collections" -> serveCollections()
method == Method.GET && uri.startsWith("/api/status/") -> serveChangeStatus(uri)
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found")
}
}
private fun serveWebPage(): Response {
return newFixedLengthResponse(
Response.Status.OK,
"text/html; charset=utf-8",
AddonWebPage.getHtml(context, webConfigMode)
)
return newFixedLengthResponse(Response.Status.OK, "text/html", AddonWebPage.getHtml())
}
private fun serveLogo(): Response {
@ -144,22 +88,16 @@ class AddonConfigServer(
}
}
private fun serveCollections(): Response {
val collections = currentPageStateProvider().collections
val json = gson.toJson(collections)
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
}
private fun serveAddonList(): Response {
val addons = currentPageStateProvider().addons
val json = gson.toJson(addons)
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
return newFixedLengthResponse(Response.Status.OK, "application/json", json)
}
private fun servePageState(): Response {
val state = currentPageStateProvider()
val json = gson.toJson(state)
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
return newFixedLengthResponse(Response.Status.OK, "application/json", json)
}
private fun handleAddonUpdate(session: IHTTPSession): Response {
@ -178,25 +116,16 @@ class AddonConfigServer(
val urls = parseStringList(parsed["urls"])
val catalogOrderKeys = parseStringList(parsed["catalogOrderKeys"])
val disabledCatalogKeys = parseStringList(parsed["disabledCatalogKeys"])
val collectionsRaw = parsed["collections"]
val collectionsJson = if (collectionsRaw != null) gson.toJson(collectionsRaw) else null
val disabledCollectionKeys = parseStringList(parsed["disabledCollectionKeys"])
sanitizePendingAddonChange(
mode = webConfigMode,
proposedChange = PendingAddonChange(
proposedUrls = urls,
proposedCatalogOrderKeys = catalogOrderKeys,
proposedDisabledCatalogKeys = disabledCatalogKeys,
proposedCollectionsJson = collectionsJson,
proposedDisabledCollectionKeys = disabledCollectionKeys
),
currentState = currentPageStateProvider()
PendingAddonChange(
proposedUrls = urls,
proposedCatalogOrderKeys = catalogOrderKeys,
proposedDisabledCatalogKeys = disabledCatalogKeys
)
} catch (e: Exception) {
val error = mapOf("error" to "Invalid request body")
return newFixedLengthResponse(
Response.Status.BAD_REQUEST,
"application/json; charset=utf-8",
"application/json",
gson.toJson(error)
)
}
@ -205,7 +134,7 @@ class AddonConfigServer(
onChangeProposed(change)
val response = mapOf("status" to "pending_confirmation", "id" to change.id)
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", gson.toJson(response))
return newFixedLengthResponse(Response.Status.OK, "application/json", gson.toJson(response))
}
private fun serveChangeStatus(uri: String): Response {
@ -213,7 +142,7 @@ class AddonConfigServer(
val change = pendingChanges[id]
val status = change?.status?.name?.lowercase() ?: "not_found"
val response = mapOf("status" to status)
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", gson.toJson(response))
return newFixedLengthResponse(Response.Status.OK, "application/json", gson.toJson(response))
}
private fun parseStringList(rawValue: Any?): List<String> {
@ -227,8 +156,6 @@ class AddonConfigServer(
companion object {
fun startOnAvailablePort(
context: Context,
webConfigMode: WebConfigMode = WebConfigMode.FULL,
currentPageStateProvider: () -> PageState,
onChangeProposed: (PendingAddonChange) -> Unit,
logoProvider: (() -> ByteArray?)? = null,
@ -237,14 +164,7 @@ class AddonConfigServer(
): AddonConfigServer? {
for (port in startPort until startPort + maxAttempts) {
try {
val server = AddonConfigServer(
context = context,
webConfigMode = webConfigMode,
currentPageStateProvider = currentPageStateProvider,
onChangeProposed = onChangeProposed,
logoProvider = logoProvider,
port = port
)
val server = AddonConfigServer(currentPageStateProvider, onChangeProposed, logoProvider, port)
server.start(SOCKET_READ_TIMEOUT, false)
return server
} catch (e: Exception) {
@ -255,33 +175,3 @@ class AddonConfigServer(
}
}
}
internal fun sanitizePendingAddonChange(
mode: AddonConfigServer.WebConfigMode,
proposedChange: AddonConfigServer.PendingAddonChange,
currentState: AddonConfigServer.PageState
): AddonConfigServer.PendingAddonChange {
if (mode.allowAddonManagement && mode.allowCatalogManagement) {
return proposedChange
}
return proposedChange.copy(
proposedUrls = if (mode.allowAddonManagement) {
proposedChange.proposedUrls
} else {
currentState.addons.map { it.url }
},
proposedCatalogOrderKeys = if (mode.allowCatalogManagement) {
proposedChange.proposedCatalogOrderKeys
} else {
currentState.catalogs.map { it.key }
},
proposedDisabledCatalogKeys = if (mode.allowCatalogManagement) {
proposedChange.proposedDisabledCatalogKeys
} else {
currentState.catalogs
.filter { it.isDisabled }
.map { it.disableKey }
}
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
package com.nuvio.tv.core.server
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD
@ -9,7 +8,6 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class RepositoryConfigServer(
private val context: Context,
private val currentRepositoriesProvider: () -> List<RepositoryInfo>,
private val onChangeProposed: (PendingRepoChange) -> Unit,
private val logoProvider: (() -> ByteArray?)? = null,
@ -56,7 +54,7 @@ class RepositoryConfigServer(
}
private fun serveWebPage(): Response {
return newFixedLengthResponse(Response.Status.OK, "text/html", RepositoryWebPage.getHtml(context))
return newFixedLengthResponse(Response.Status.OK, "text/html", RepositoryWebPage.getHtml())
}
private fun serveLogo(): Response {
@ -120,7 +118,6 @@ class RepositoryConfigServer(
companion object {
fun startOnAvailablePort(
context: Context,
currentRepositoriesProvider: () -> List<RepositoryInfo>,
onChangeProposed: (PendingRepoChange) -> Unit,
logoProvider: (() -> ByteArray?)? = null,
@ -129,7 +126,7 @@ class RepositoryConfigServer(
): RepositoryConfigServer? {
for (port in startPort until startPort + maxAttempts) {
try {
val server = RepositoryConfigServer(context, currentRepositoriesProvider, onChangeProposed, logoProvider, port)
val server = RepositoryConfigServer(currentRepositoriesProvider, onChangeProposed, logoProvider, port)
server.start(SOCKET_READ_TIMEOUT, false)
return server
} catch (e: Exception) {

View file

@ -1,27 +1,14 @@
package com.nuvio.tv.core.server
import android.content.Context
import android.content.res.Configuration
import com.nuvio.tv.R
import java.util.Locale
object RepositoryWebPage {
fun getHtml(baseContext: Context): String {
val tag = baseContext.getSharedPreferences("app_locale", Context.MODE_PRIVATE)
.getString("locale_tag", null)
val context = if (!tag.isNullOrEmpty()) {
val config = Configuration(baseContext.resources.configuration)
config.setLocale(Locale.forLanguageTag(tag))
baseContext.createConfigurationContext(config)
} else baseContext
return """
fun getHtml(): String = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>${context.getString(R.string.app_name)} - ${context.getString(R.string.web_manage_repos_title)}</title>
<title>NuvioTV - Manage Repositories</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
* {
@ -318,30 +305,30 @@ object RepositoryWebPage {
<div class="page">
<div class="header">
<img src="/logo.png" alt="NuvioTV" class="header-logo">
<p>${context.getString(R.string.web_manage_repos_subtitle)}</p>
<p>Manage your repositories</p>
</div>
<div class="add-section">
<label>${context.getString(R.string.web_add_repo_url)}</label>
<label>Add repository by URL</label>
<div class="add-row">
<input type="url" id="repoUrl" placeholder="${context.getString(R.string.web_placeholder_url)}" autocomplete="off" autocapitalize="off" spellcheck="false">
<button class="btn" id="addBtn" onclick="addRepo()">${context.getString(R.string.web_btn_add)}</button>
<input type="url" id="repoUrl" placeholder="https://example.com/manifest.json" autocomplete="off" autocapitalize="off" spellcheck="false">
<button class="btn" id="addBtn" onclick="addRepo()">Add</button>
</div>
<div class="add-error" id="addError"></div>
</div>
<div class="section-label">${context.getString(R.string.web_installed)}</div>
<div class="section-label">Installed</div>
<ul class="repo-list" id="repoList"></ul>
<div class="empty-state" id="emptyState">${context.getString(R.string.web_no_repos)}</div>
<div class="empty-state" id="emptyState">No repositories installed</div>
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">${context.getString(R.string.web_btn_save)}</button>
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">Save Changes</button>
</div>
<div class="status-overlay" id="statusOverlay">
<div class="status-content" id="statusContent"></div>
</div>
<div class="connection-bar" id="connectionBar">${context.getString(R.string.web_connection_lost)}</div>
<div class="connection-bar" id="connectionBar">Connection to TV lost</div>
<script>
var repos = [];
@ -398,13 +385,13 @@ function renderList() {
li.innerHTML =
'<div class="repo-info">' +
'<div class="repo-name">' + escapeHtml(repo.name || repo.url) +
(repo.isNew ? '<span class="badge-new">${context.getString(R.string.web_badge_new).replace("'", "\\'")}</span>' : '') +
(repo.isNew ? '<span class="badge-new">New</span>' : '') +
'</div>' +
(repo.description ? '<div class="repo-desc">' + escapeHtml(repo.description) + '</div>' : '') +
'<div class="repo-url">' + escapeHtml(repo.url) + '</div>' +
'</div>' +
'<div class="repo-actions">' +
'<button class="btn btn-remove" onclick="removeRepo(' + i + ')">${context.getString(R.string.web_btn_remove).replace("'", "\\'")}</button>' +
'<button class="btn btn-remove" onclick="removeRepo(' + i + ')">Remove</button>' +
'</div>';
list.appendChild(li);
@ -426,7 +413,7 @@ async function addRepo() {
url = url.replace(/\/+$/, '');
if (repos.some(function(r) { return r.url === url; })) {
errorEl.textContent = '${context.getString(R.string.web_error_repo_exists).replace("'", "\\'")}';
errorEl.textContent = 'This repository is already in the list';
errorEl.style.display = 'block';
setTimeout(function() { errorEl.style.display = 'none'; }, 3000);
return;
@ -464,7 +451,7 @@ async function saveChanges() {
saveBtn.disabled = false;
}
} catch (e) {
showErrorStatus('${context.getString(R.string.web_error_failed_save).replace("'", "\\'")}');
showErrorStatus('Failed to save. Check your connection to the TV.');
saveBtn.disabled = false;
}
}
@ -474,8 +461,8 @@ function showPendingStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="spinner"></div></div>' +
'<div class="status-title">${context.getString(R.string.web_status_waiting_tv).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_waiting_tv).replace("'", "\\'")}</div>';
'<div class="status-title">Waiting for TV</div>' +
'<div class="status-message">Please confirm the changes on your TV to apply them.</div>';
content.className = 'status-content';
overlay.classList.add('visible');
}
@ -484,8 +471,8 @@ function showSuccessStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></div></div>' +
'<div class="status-title">${context.getString(R.string.web_status_changes_applied).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_repo_updated).replace("'", "\\'")}</div>';
'<div class="status-title">Changes Applied</div>' +
'<div class="status-message">Your repository configuration has been updated on the TV.</div>';
content.className = 'status-content status-success';
setTimeout(dismissStatus, 2500);
}
@ -494,8 +481,8 @@ function showRejectedStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></div></div>' +
'<div class="status-title">${context.getString(R.string.web_status_changes_rejected).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_changes_rejected).replace("'", "\\'")}</div>';
'<div class="status-title">Changes Rejected</div>' +
'<div class="status-message">The changes were declined on the TV. Your list has been reverted.</div>';
content.className = 'status-content status-rejected';
setTimeout(function() {
repos = JSON.parse(JSON.stringify(originalRepos));
@ -509,9 +496,9 @@ function showErrorStatus(msg) {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg></div></div>' +
'<div class="status-title">${context.getString(R.string.web_status_error).replace("'", "\\'")}</div>' +
'<div class="status-title">Something Went Wrong</div>' +
'<div class="status-message">' + escapeHtml(msg) + '</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
content.className = 'status-content status-error';
overlay.classList.add('visible');
}
@ -520,9 +507,9 @@ function showTimeoutStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div></div>' +
'<div class="status-title">${context.getString(R.string.web_status_timeout).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_timeout).replace("'", "\\'")}</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
'<div class="status-title">Timed Out</div>' +
'<div class="status-message">No response from the TV. Please try again.</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
content.className = 'status-content status-error';
}
@ -530,9 +517,9 @@ function showDisconnectedStatus() {
var content = document.getElementById('statusContent');
content.innerHTML =
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22M16.72 11.06A10.94 10.94 0 0 1 19 12.55M5 12.55a10.94 10.94 0 0 1 5.17-2.39M10.71 5.05A16 16 0 0 1 22.56 9M1.42 9a15.91 15.91 0 0 1 4.7-2.88M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/></svg></div></div>' +
'<div class="status-title">${context.getString(R.string.web_connection_lost).replace("'", "\\'")}</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_connection_lost).replace("'", "\\'")}</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
'<div class="status-title">Connection Lost</div>' +
'<div class="status-message">The TV server is no longer reachable. The changes may have been applied.</div>' +
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
content.className = 'status-content status-error';
}
@ -604,5 +591,4 @@ loadRepos();
</body>
</html>
""".trimIndent()
}
}

View file

@ -50,7 +50,6 @@ class AddonSyncService @Inject constructor(
}
val localUrls = addonPreferences.installedAddonUrls.first()
val userSetNames = addonPreferences.userSetNames.first()
Log.d(TAG, "pushToRemote: localUrls count=${localUrls.size} for profile $profileId")
val params = buildJsonObject {
@ -59,10 +58,6 @@ class AddonSyncService @Inject constructor(
addJsonObject {
put("url", url)
put("sort_order", index)
val name = userSetNames[url]
if (!name.isNullOrBlank()) {
put("name", name)
}
}
}
})
@ -101,14 +96,6 @@ class AddonSyncService @Inject constructor(
.decodeList<SupabaseAddon>()
}
val nameMap = mutableMapOf<String, String>()
remoteAddons.forEach { addon ->
if (!addon.name.isNullOrBlank()) {
nameMap[canonicalizeUrl(addon.url)] = addon.name
}
}
addonPreferences.setUserSetNames(nameMap)
Result.success(
remoteAddons
.sortedBy { it.sortOrder }
@ -119,17 +106,4 @@ class AddonSyncService @Inject constructor(
Result.failure(e)
}
}
private fun canonicalizeUrl(url: String): String {
val trimmed = url.trim().trimEnd('/')
val queryStart = trimmed.indexOf('?')
val path = if (queryStart >= 0) trimmed.substring(0, queryStart) else trimmed
val query = if (queryStart >= 0) trimmed.substring(queryStart) else ""
val cleanPath = if (path.endsWith("/manifest.json", ignoreCase = true)) {
path.dropLast("/manifest.json".length).trimEnd('/')
} else {
path.trimEnd('/')
}
return cleanPath + query
}
}

View file

@ -1,172 +0,0 @@
package com.nuvio.tv.core.sync
import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.CollectionsDataStore
import com.nuvio.tv.data.remote.supabase.SupabaseCollectionBlob
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "CollectionSyncService"
private const val PUSH_DEBOUNCE_MS = 1500L
@Singleton
class CollectionSyncService @Inject constructor(
private val postgrest: Postgrest,
private val authManager: AuthManager,
private val collectionsDataStore: CollectionsDataStore,
private val profileManager: ProfileManager
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
var isSyncingFromRemote: Boolean = false
private var pushJob: Job? = null
init {
observeLocalChangesAndPush()
}
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
return try {
block()
} catch (e: Exception) {
if (!authManager.refreshSessionIfJwtExpired(e)) throw e
block()
}
}
/**
* Push local collections JSON to Supabase via RPC.
* Uses a SECURITY DEFINER function to handle RLS for linked devices.
*/
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try {
val profileId = profileManager.activeProfileId.value
val json = collectionsDataStore.exportCurrentProfileJson()
val collectionsJsonElement = if (json.isNullOrBlank()) {
JsonArray(emptyList())
} else {
Json.parseToJsonElement(json)
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_collections_json", collectionsJsonElement)
}
withJwtRefreshRetry {
postgrest.rpc("sync_push_collections", params)
}
Log.d(TAG, "Pushed collections to remote for profile $profileId")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to push collections to remote", e)
Result.failure(e)
}
}
/**
* Pull remote collections JSON and apply locally.
* Returns true if local state was updated.
*/
suspend fun pullFromRemote(): Result<Boolean> = withContext(Dispatchers.IO) {
try {
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_collections", params)
}
val rows = response.decodeList<SupabaseCollectionBlob>()
val blob = rows.firstOrNull()
if (blob == null) {
Log.d(TAG, "No remote collections for profile $profileId; keeping local")
return@withContext Result.success(false)
}
val remoteJson = blob.collectionsJson.toString()
val remoteCollections = collectionsDataStore.importFromJson(remoteJson)
// Preserve local if remote is empty but local has data
val localCollections = collectionsDataStore.getCurrentCollections()
if (remoteCollections.isEmpty() && localCollections.isNotEmpty()) {
Log.w(TAG, "Remote collections empty while local has ${localCollections.size}; preserving local")
return@withContext Result.success(false)
}
// Check if different
val localJson = collectionsDataStore.exportCurrentProfileJson() ?: ""
if (remoteJson == localJson) {
Log.d(TAG, "Remote collections already match local for profile $profileId")
return@withContext Result.success(false)
}
isSyncingFromRemote = true
try {
collectionsDataStore.setCollections(remoteCollections)
} finally {
isSyncingFromRemote = false
}
Log.d(TAG, "Applied ${remoteCollections.size} remote collections for profile $profileId")
Result.success(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull collections from remote", e)
Result.failure(e)
}
}
/**
* Trigger a debounced push to remote after a local change.
*/
fun triggerPush() {
if (isSyncingFromRemote) return
if (!authManager.isAuthenticated) return
pushJob?.cancel()
pushJob = scope.launch {
delay(500)
pushToRemote()
}
}
/**
* Observe local collections DataStore changes and auto-push to remote.
*/
@OptIn(kotlinx.coroutines.FlowPreview::class)
private fun observeLocalChangesAndPush() {
scope.launch {
collectionsDataStore.collections
.drop(1) // skip initial value
.distinctUntilChanged()
.debounce(PUSH_DEBOUNCE_MS)
.collect {
if (!authManager.isAuthenticated) return@collect
if (isSyncingFromRemote) return@collect
pushToRemote()
}
}
}
}

View file

@ -1,209 +0,0 @@
package com.nuvio.tv.core.sync
import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.CollectionsDataStore
import com.nuvio.tv.data.local.LayoutPreferenceDataStore
import com.nuvio.tv.data.remote.supabase.SupabaseHomeCatalogSettingsBlob
import com.nuvio.tv.domain.repository.AddonRepository
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "HomeCatalogSettingsSyncService"
private const val SETTINGS_SYNC_PLATFORM = "tv"
private const val PAYLOAD_SAMPLE_LIMIT = 5
@Serializable
data class SyncCatalogItem(
@SerialName("addon_id") val addonId: String,
val type: String,
@SerialName("catalog_id") val catalogId: String,
val enabled: Boolean = true,
val order: Int = 0,
@SerialName("custom_title") val customTitle: String = "",
@SerialName("is_collection") val isCollection: Boolean = false,
@SerialName("collection_id") val collectionId: String = "",
)
@Serializable
data class SyncHomeCatalogPayload(
val items: List<SyncCatalogItem> = emptyList(),
)
@Singleton
class HomeCatalogSettingsSyncService @Inject constructor(
private val postgrest: Postgrest,
private val authManager: AuthManager,
private val layoutPreferenceDataStore: LayoutPreferenceDataStore,
private val profileManager: ProfileManager,
private val addonRepository: AddonRepository,
private val collectionsDataStore: CollectionsDataStore
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
@Volatile
var isSyncingFromRemote: Boolean = false
private var pushJob: Job? = null
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
return try {
block()
} catch (e: Exception) {
if (!authManager.refreshSessionIfJwtExpired(e)) throw e
block()
}
}
suspend fun pushToRemote(reason: String = "unspecified"): Result<Unit> = withContext(Dispatchers.IO) {
try {
val profileId = profileManager.activeProfileId.value
val payload = loadLocalPayload()
Log.d(TAG, "Push start profile=$profileId reason=$reason ${payload.summary()}")
pushPayload(profileId, payload)
Log.d(TAG, "Push success profile=$profileId reason=$reason")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Push failed reason=$reason", e)
Result.failure(e)
}
}
suspend fun pullFromRemote(): Result<Boolean> = withContext(Dispatchers.IO) {
try {
val profileId = profileManager.activeProfileId.value
val localState = layoutPreferenceDataStore.getHomeCatalogSettingsState()
Log.d(TAG, "Pull start profile=$profileId ${localState.summary()}")
if (localState.disabledKeys.any(::hasLegacyHomeCatalogDisabledKeyFormat)) {
val localPayload = loadLocalPayload()
if (localPayload.items.isNotEmpty()) {
isSyncingFromRemote = true
try {
layoutPreferenceDataStore.applyCatalogSettingsFromRemote(localPayload)
} finally {
isSyncingFromRemote = false
}
Log.i(TAG, "Migrated legacy local keys profile=$profileId ${localPayload.summary()} (no startup push)")
return@withContext Result.success(true)
}
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_platform", SETTINGS_SYNC_PLATFORM)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_home_catalog_settings", params)
}
val rows = response.decodeList<SupabaseHomeCatalogSettingsBlob>()
val blob = rows.firstOrNull()
if (blob == null) {
Log.d(TAG, "No remote row profile=$profileId; preserving local (startup is pull-only)")
return@withContext Result.success(false)
}
val remotePayload = runCatching {
json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson)
}.getOrNull()
if (remotePayload == null) {
Log.w(TAG, "Pull parse failure profile=$profileId")
return@withContext Result.success(false)
}
Log.d(TAG, "Pull remote payload profile=$profileId ${remotePayload.summary()}")
if (remotePayload.items.isEmpty()) {
Log.d(TAG, "Remote payload empty profile=$profileId; preserving local (startup is pull-only)")
return@withContext Result.success(false)
}
isSyncingFromRemote = true
try {
layoutPreferenceDataStore.applyCatalogSettingsFromRemote(remotePayload)
} finally {
isSyncingFromRemote = false
}
Log.d(TAG, "Pull apply success profile=$profileId ${remotePayload.summary()}")
Result.success(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull home catalog settings", e)
Result.failure(e)
}
}
fun triggerPush() {
if (isSyncingFromRemote) return
if (!authManager.isAuthenticated) return
pushJob?.cancel()
pushJob = scope.launch {
delay(500)
pushToRemote(reason = "triggerPush")
}
}
private suspend fun loadLocalPayload(): SyncHomeCatalogPayload {
val addons = addonRepository.getInstalledAddons().first()
val collections = collectionsDataStore.getCurrentCollections()
return layoutPreferenceDataStore.exportCatalogSettingsToSyncPayload(addons, collections)
}
private suspend fun pushPayload(profileId: Int, payload: SyncHomeCatalogPayload) {
val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload)
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_settings_json", jsonElement)
put("p_platform", SETTINGS_SYNC_PLATFORM)
}
withJwtRefreshRetry {
postgrest.rpc("sync_push_home_catalog_settings", params)
}
}
}
private fun LocalHomeCatalogSettingsState.summary(): String {
val orderSample = orderKeys.take(PAYLOAD_SAMPLE_LIMIT)
val disabledSample = disabledKeys.take(PAYLOAD_SAMPLE_LIMIT)
val titleSample = customTitles.keys.take(PAYLOAD_SAMPLE_LIMIT)
return "localState(order=${orderKeys.size}, disabled=${disabledKeys.size}, titles=${customTitles.size}, orderSample=$orderSample, disabledSample=$disabledSample, titleSample=$titleSample)"
}
private fun SyncHomeCatalogPayload.summary(): String {
val disabledCount = items.count { !it.enabled }
val collectionCount = items.count { it.isCollection }
val sample = items.take(PAYLOAD_SAMPLE_LIMIT).joinToString(separator = " | ") { item ->
if (item.isCollection) {
"collection:${item.collectionId},enabled=${item.enabled},order=${item.order}"
} else {
"catalog:${item.addonId}/${item.type}/${item.catalogId},enabled=${item.enabled},order=${item.order}"
}
}
return "payload(items=${items.size}, disabled=$disabledCount, collections=$collectionCount, sample=[$sample])"
}

View file

@ -1,153 +0,0 @@
package com.nuvio.tv.core.sync
import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.CatalogDescriptor
import com.nuvio.tv.domain.model.Collection
internal data class LocalHomeCatalogSettingsState(
val orderKeys: List<String> = emptyList(),
val disabledKeys: Set<String> = emptySet(),
val customTitles: Map<String, String> = emptyMap()
)
private data class HomeCatalogSyncEntry(
val key: String,
val addonId: String = "",
val addonBaseUrl: String = "",
val type: String = "",
val catalogId: String = "",
val catalogName: String = "",
val isCollection: Boolean = false,
val collectionId: String = ""
)
internal fun homeCatalogKey(addonId: String, type: String, catalogId: String): String {
return "${addonId}_${type}_${catalogId}"
}
internal fun homeCollectionKey(collectionId: String): String {
return "collection_${collectionId}"
}
internal fun homeLegacyDisabledCatalogKey(
addonBaseUrl: String,
type: String,
catalogId: String,
catalogName: String
): String {
return "${addonBaseUrl}_${type}_${catalogId}_${catalogName}"
}
internal fun hasLegacyHomeCatalogDisabledKeyFormat(key: String): Boolean {
return key.contains("://")
}
internal fun isHomeCatalogDisabled(
disabledKeys: Set<String>,
addonId: String,
addonBaseUrl: String,
type: String,
catalogId: String,
catalogName: String
): Boolean {
return homeCatalogKey(addonId, type, catalogId) in disabledKeys ||
homeLegacyDisabledCatalogKey(addonBaseUrl, type, catalogId, catalogName) in disabledKeys
}
internal fun buildHomeCatalogSyncPayload(
addons: List<Addon>,
collections: List<Collection>,
localState: LocalHomeCatalogSettingsState
): SyncHomeCatalogPayload {
val catalogEntries = buildCatalogEntries(addons)
val collectionEntries = collections.map { collection ->
HomeCatalogSyncEntry(
key = homeCollectionKey(collection.id),
isCollection = true,
collectionId = collection.id
)
}
val entryByKey = (catalogEntries + collectionEntries).associateBy { it.key }
val catalogKeys = catalogEntries.map { it.key }
val collectionKeys = collectionEntries.map { it.key }
val savedValid = localState.orderKeys
.asSequence()
.filter { it in entryByKey }
.distinct()
.toList()
val savedSet = savedValid.toSet()
val mergedOrder = savedValid +
catalogKeys.filterNot { it in savedSet } +
collectionKeys.filterNot { it in savedSet }
val items = mergedOrder.mapIndexedNotNull { index, key ->
val entry = entryByKey[key] ?: return@mapIndexedNotNull null
if (entry.isCollection) {
SyncCatalogItem(
addonId = "",
type = "",
catalogId = "",
enabled = key !in localState.disabledKeys,
order = index,
customTitle = localState.customTitles[key].orEmpty(),
isCollection = true,
collectionId = entry.collectionId
)
} else {
SyncCatalogItem(
addonId = entry.addonId,
type = entry.type,
catalogId = entry.catalogId,
enabled = !isHomeCatalogDisabled(
disabledKeys = localState.disabledKeys,
addonId = entry.addonId,
addonBaseUrl = entry.addonBaseUrl,
type = entry.type,
catalogId = entry.catalogId,
catalogName = entry.catalogName
),
order = index,
customTitle = localState.customTitles[key].orEmpty(),
isCollection = false,
collectionId = ""
)
}
}
return SyncHomeCatalogPayload(items = items)
}
private fun buildCatalogEntries(addons: List<Addon>): List<HomeCatalogSyncEntry> {
val entries = mutableListOf<HomeCatalogSyncEntry>()
val seenKeys = mutableSetOf<String>()
addons.forEach { addon ->
addon.catalogs
.filter { it.shouldShowOnHomeForSync() }
.forEach { catalog ->
val key = homeCatalogKey(addon.id, catalog.apiType, catalog.id)
if (seenKeys.add(key)) {
entries.add(
HomeCatalogSyncEntry(
key = key,
addonId = addon.id,
addonBaseUrl = addon.baseUrl,
type = catalog.apiType,
catalogId = catalog.id,
catalogName = catalog.name
)
)
}
}
}
return entries
}
private fun CatalogDescriptor.shouldShowOnHomeForSync(): Boolean {
if (extra.any { it.name.equals("search", ignoreCase = true) && it.isRequired }) {
return false
}
return !hasExplicitShowInHome || showInHome
}

View file

@ -21,8 +21,6 @@ import javax.inject.Singleton
private const val TAG = "LibrarySyncService"
private const val PULL_PAGE_SIZE = 500
@Singleton
class LibrarySyncService @Inject constructor(
private val authManager: AuthManager,
@ -68,7 +66,6 @@ class LibrarySyncService @Inject constructor(
item.genres.forEach { genre -> add(kotlinx.serialization.json.JsonPrimitive(genre)) }
})
put("addon_base_url", item.addonBaseUrl)
put("added_at", item.addedAt)
}
}
})
@ -94,29 +91,17 @@ class LibrarySyncService @Inject constructor(
}
val profileId = profileManager.activeProfileId.value
val allItems = mutableListOf<SupabaseLibraryItem>()
var offset = 0
while (true) {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_limit", PULL_PAGE_SIZE)
put("p_offset", offset)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_library", params)
}
val page = response.decodeList<SupabaseLibraryItem>()
allItems.addAll(page)
Log.d(TAG, "pullFromRemote: fetched page at offset=$offset, got ${page.size} items")
if (page.size < PULL_PAGE_SIZE) break
offset += PULL_PAGE_SIZE
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_library", params)
}
val remote = response.decodeList<SupabaseLibraryItem>()
Log.d(TAG, "pullFromRemote: fetched ${allItems.size} total library items for profile $profileId")
Log.d(TAG, "pullFromRemote: fetched ${remote.size} library items from Supabase for profile $profileId")
Result.success(allItems.map { entry ->
Result.success(remote.map { entry ->
SavedLibraryItem(
id = entry.contentId,
type = entry.contentType,
@ -128,8 +113,7 @@ class LibrarySyncService @Inject constructor(
releaseInfo = entry.releaseInfo,
imdbRating = entry.imdbRating,
genres = entry.genres,
addonBaseUrl = entry.addonBaseUrl,
addedAt = entry.addedAt
addonBaseUrl = entry.addonBaseUrl
)
})
} catch (e: Exception) {

View file

@ -5,7 +5,6 @@ import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.PluginDataStore
import com.nuvio.tv.data.remote.supabase.SupabasePlugin
import com.nuvio.tv.domain.model.RemotePluginInfo
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
@ -61,7 +60,6 @@ class PluginSyncService @Inject constructor(
put("name", repo.name)
put("enabled", repo.enabled)
put("sort_order", index)
put("repo_type", repo.type.name)
}
}
})
@ -80,7 +78,7 @@ class PluginSyncService @Inject constructor(
}
}
suspend fun getRemoteRepoUrls(): Result<List<RemotePluginInfo>> = withContext(Dispatchers.IO) {
suspend fun getRemoteRepoUrls(): Result<List<String>> = withContext(Dispatchers.IO) {
try {
val effectiveUserId = authManager.getEffectiveUserId(fallbackToOwnIdOnFailure = false)
?: return@withContext Result.failure(
@ -103,7 +101,7 @@ class PluginSyncService @Inject constructor(
Result.success(
remotePlugins
.sortedBy { it.sortOrder }
.map { RemotePluginInfo(url = it.url, repoType = it.repoType) }
.map { it.url }
)
} catch (e: Exception) {
Log.e(TAG, "Failed to get remote repo URLs", e)

View file

@ -1,393 +0,0 @@
package com.nuvio.tv.core.sync
import android.os.SystemClock
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.ProfileDataStoreFactory
import com.nuvio.tv.data.remote.supabase.SupabaseProfileSettingsBlob
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "ProfileSettingsSyncService"
private const val SETTINGS_PUSH_DEBOUNCE_MS = 1500L
private const val FOREGROUND_PULL_DELAY_MS = 2500L
private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 60_000L
private const val SETTINGS_SYNC_PLATFORM = "tv"
@Singleton
class ProfileSettingsSyncService @Inject constructor(
private val authManager: AuthManager,
private val postgrest: Postgrest,
private val profileManager: ProfileManager,
private val profileDataStoreFactory: ProfileDataStoreFactory
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val syncMutex = Mutex()
@Volatile
private var applyingRemoteBlob: Boolean = false
@Volatile
private var skipNextPushSignature: String? = null
private var foregroundPullJob: Job? = null
private var lastForegroundPullAtMs: Long = 0L
private val syncedFeatures = listOf(
"theme_settings",
"layout_settings",
"player_settings",
"trailer_settings",
"tmdb_settings",
"mdblist_settings",
"animeskip_settings",
"track_preference"
)
private val catalogKeysExcludedFromBlob = setOf(
"home_catalog_order_keys",
"disabled_home_catalog_keys",
"custom_catalog_titles"
)
init {
observeLocalSettingsChangesAndSync()
}
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
return try {
block()
} catch (e: Exception) {
if (!authManager.refreshSessionIfJwtExpired(e)) throw e
block()
}
}
suspend fun pushCurrentProfileToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
syncMutex.withLock {
try {
val profileId = profileManager.activeProfileId.value
val settingsJson = exportSettingsBlob(profileId)
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_settings_json", settingsJson)
put("p_platform", SETTINGS_SYNC_PLATFORM)
}
withJwtRefreshRetry {
postgrest.rpc("sync_push_profile_settings_blob", params)
}
Log.d(TAG, "Pushed profile settings blob for profile $profileId")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to push profile settings blob", e)
Result.failure(e)
}
}
}
suspend fun pullCurrentProfileFromRemote(): Result<Boolean> = withContext(Dispatchers.IO) {
syncMutex.withLock {
try {
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_platform", SETTINGS_SYNC_PLATFORM)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_profile_settings_blob", params)
}
val rows = response.decodeList<SupabaseProfileSettingsBlob>()
val blob = rows.firstOrNull()?.settingsJson
if (blob == null) {
Log.d(TAG, "No remote profile settings blob for profile $profileId; keeping local settings")
return@withLock Result.success(false)
}
val featuresJson = blob["features"]?.jsonObject ?: return@withLock Result.success(false)
val remoteSignature = buildSettingsSignature(featuresJson)
val localSignature = buildSettingsSignature(profileId)
if (remoteSignature == localSignature) {
Log.d(TAG, "Remote profile settings already match local for profile $profileId")
return@withLock Result.success(false)
}
importSettingsBlob(profileId, featuresJson)
skipNextPushSignature = remoteSignature
Log.d(TAG, "Applied remote profile settings blob for profile $profileId")
Result.success(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull profile settings blob", e)
Result.failure(e)
}
}
}
fun requestForegroundPull(force: Boolean = false) {
if (!authManager.isAuthenticated) return
val now = SystemClock.elapsedRealtime()
if (!force && foregroundPullJob?.isActive == true) return
if (!force && now - lastForegroundPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS) return
foregroundPullJob = scope.launch {
if (!force) {
delay(FOREGROUND_PULL_DELAY_MS)
}
if (!authManager.isAuthenticated) return@launch
lastForegroundPullAtMs = SystemClock.elapsedRealtime()
pullCurrentProfileFromRemote()
}
}
private suspend fun exportSettingsBlob(profileId: Int): JsonObject {
val features = buildJsonObject {
syncedFeatures.forEach { feature ->
val prefs = profileDataStoreFactory.get(profileId, feature).data.first()
val serialized = buildJsonObject {
prefs.asMap().forEach { (key, rawValue) ->
if (feature == "layout_settings" && key.name in catalogKeysExcludedFromBlob) return@forEach
val encoded = encodePreferenceValue(rawValue) ?: return@forEach
put(key.name, encoded)
}
}
put(feature, serialized)
}
}
return buildJsonObject {
put("version", 1)
put("features", features)
}
}
private suspend fun importSettingsBlob(profileId: Int, featuresJson: JsonObject) {
applyingRemoteBlob = true
try {
syncedFeatures.forEach { feature ->
val featureJson = featuresJson[feature]?.jsonObject ?: return@forEach
profileDataStoreFactory.get(profileId, feature).edit { mutablePrefs ->
val preservedEntries = if (feature == "layout_settings") {
val entries = mutableMapOf<Preferences.Key<*>, Any>()
catalogKeysExcludedFromBlob.forEach { keyName ->
val strKey = stringPreferencesKey(keyName)
mutablePrefs[strKey]?.let { entries[strKey] = it }
val boolKey = booleanPreferencesKey(keyName)
mutablePrefs[boolKey]?.let { entries[boolKey] = it }
}
entries
} else {
emptyMap()
}
mutablePrefs.clear()
featureJson.forEach { (keyName, encodedValue) ->
if (feature == "layout_settings" && keyName in catalogKeysExcludedFromBlob) return@forEach
applyEncodedPreference(mutablePrefs, keyName, encodedValue)
}
@Suppress("UNCHECKED_CAST")
preservedEntries.forEach { (key, value) ->
when (value) {
is String -> mutablePrefs[key as Preferences.Key<String>] = value
is Boolean -> mutablePrefs[key as Preferences.Key<Boolean>] = value
is Int -> mutablePrefs[key as Preferences.Key<Int>] = value
is Long -> mutablePrefs[key as Preferences.Key<Long>] = value
is Float -> mutablePrefs[key as Preferences.Key<Float>] = value
is Double -> mutablePrefs[key as Preferences.Key<Double>] = value
}
}
}
}
} finally {
applyingRemoteBlob = false
}
}
private fun observeLocalSettingsChangesAndSync() {
scope.launch {
profileManager.activeProfileId
.flatMapLatest { profileId ->
val featureFlows = syncedFeatures.map { feature ->
profileDataStoreFactory.get(profileId, feature).data
.map { prefs ->
"$feature={${buildFeatureSignature(prefs, feature)}}"
}
}
combine(featureFlows) { signatures ->
signatures.joinToString(separator = "||")
}
}
.drop(1)
.distinctUntilChanged()
.debounce(SETTINGS_PUSH_DEBOUNCE_MS)
.collect { signature ->
if (!authManager.isAuthenticated) return@collect
if (applyingRemoteBlob) return@collect
if (signature == skipNextPushSignature) {
skipNextPushSignature = null
return@collect
}
pushCurrentProfileToRemote()
}
}
}
private suspend fun buildSettingsSignature(profileId: Int): String {
val signatures = ArrayList<String>(syncedFeatures.size)
syncedFeatures.forEach { feature ->
val prefs = profileDataStoreFactory.get(profileId, feature).data.first()
signatures += "$feature={${buildFeatureSignature(prefs, feature)}}"
}
return signatures.joinToString(separator = "||")
}
private fun buildSettingsSignature(featuresJson: JsonObject): String {
return syncedFeatures.joinToString(separator = "||") { feature ->
val featureJson = featuresJson[feature]?.jsonObject ?: JsonObject(emptyMap())
"$feature={${buildFeatureSignature(featureJson)}}"
}
}
private fun buildFeatureSignature(prefs: Preferences, feature: String = ""): String {
return prefs.asMap()
.entries
.mapNotNull { (key, rawValue) ->
if (feature == "layout_settings" && key.name in catalogKeysExcludedFromBlob) return@mapNotNull null
encodePreferenceValue(rawValue)?.let { encoded ->
key.name to encoded.toString()
}
}
.sortedBy { it.first }
.joinToString(separator = "|") { (key, value) -> "$key=$value" }
}
private fun buildFeatureSignature(featureJson: JsonObject): String {
return featureJson.entries
.sortedBy { it.key }
.joinToString(separator = "|") { (key, value) -> "$key=$value" }
}
private fun encodePreferenceValue(rawValue: Any?): JsonObject? {
return when (rawValue) {
is String -> buildJsonObject {
put("type", "string")
put("value", rawValue)
}
is Boolean -> buildJsonObject {
put("type", "boolean")
put("value", rawValue)
}
is Int -> buildJsonObject {
put("type", "int")
put("value", rawValue)
}
is Long -> buildJsonObject {
put("type", "long")
put("value", rawValue)
}
is Float -> buildJsonObject {
put("type", "float")
put("value", rawValue)
}
is Double -> buildJsonObject {
put("type", "double")
put("value", rawValue)
}
is Set<*> -> {
val allStrings = rawValue.all { it is String }
if (!allStrings) return null
buildJsonObject {
put("type", "string_set")
val values = rawValue.map { it as String }.sorted()
put("value", JsonArray(values.map { JsonPrimitive(it) }))
}
}
else -> null
}
}
private fun applyEncodedPreference(
mutablePrefs: androidx.datastore.preferences.core.MutablePreferences,
keyName: String,
encodedValue: JsonElement
) {
val obj = encodedValue as? JsonObject ?: return
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return
val value = obj["value"] ?: JsonNull
when (type) {
"string" -> {
val parsed = value.jsonPrimitive.contentOrNull ?: return
mutablePrefs[stringPreferencesKey(keyName)] = parsed
}
"boolean" -> {
val parsed = value.jsonPrimitive.contentOrNull?.toBooleanStrictOrNull() ?: return
mutablePrefs[booleanPreferencesKey(keyName)] = parsed
}
"int" -> {
val parsed = value.jsonPrimitive.intOrNull ?: return
mutablePrefs[intPreferencesKey(keyName)] = parsed
}
"long" -> {
val parsed = value.jsonPrimitive.longOrNull ?: return
mutablePrefs[longPreferencesKey(keyName)] = parsed
}
"float" -> {
val parsed = value.jsonPrimitive.floatOrNull ?: return
mutablePrefs[floatPreferencesKey(keyName)] = parsed
}
"double" -> {
val parsed = value.jsonPrimitive.doubleOrNull ?: return
mutablePrefs[doublePreferencesKey(keyName)] = parsed
}
"string_set" -> {
val parsed = value.jsonArray.mapNotNull { it.jsonPrimitive.contentOrNull }.toSet()
mutablePrefs[stringSetPreferencesKey(keyName)] = parsed
}
}
}
}

View file

@ -4,9 +4,7 @@ import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.ProfileDataStore
import com.nuvio.tv.data.remote.supabase.SupabaseProfileLockState
import com.nuvio.tv.data.remote.supabase.SupabaseProfile
import com.nuvio.tv.data.remote.supabase.SupabaseProfilePinVerifyResult
import com.nuvio.tv.domain.model.UserProfile
import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.Dispatchers
@ -49,7 +47,6 @@ class ProfileSyncService @Inject constructor(
put("avatar_color_hex", profile.avatarColorHex)
put("uses_primary_addons", profile.usesPrimaryAddons)
put("uses_primary_plugins", profile.usesPrimaryPlugins)
put("avatar_id", profile.avatarId)
}
}
})
@ -81,8 +78,7 @@ class ProfileSyncService @Inject constructor(
name = entry.name,
avatarColorHex = entry.avatarColorHex,
usesPrimaryAddons = entry.usesPrimaryAddons,
usesPrimaryPlugins = entry.usesPrimaryPlugins,
avatarId = entry.avatarId
usesPrimaryPlugins = entry.usesPrimaryPlugins
)
}
@ -114,73 +110,4 @@ class ProfileSyncService @Inject constructor(
Result.failure(e)
}
}
suspend fun pullProfileLockStates(): Result<Map<Int, Boolean>> = withContext(Dispatchers.IO) {
try {
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_profile_locks")
}
val remote = response.decodeList<SupabaseProfileLockState>()
val result = remote.associate { it.profileIndex to it.pinEnabled }
Result.success(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull profile lock states", e)
Result.failure(e)
}
}
suspend fun setProfilePin(profileId: Int, pin: String, currentPin: String? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_pin", pin)
if (!currentPin.isNullOrBlank()) {
put("p_current_pin", currentPin)
}
}
withJwtRefreshRetry {
postgrest.rpc("set_profile_pin", params)
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to set profile PIN", e)
Result.failure(e)
}
}
suspend fun clearProfilePin(profileId: Int, currentPin: String? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
if (!currentPin.isNullOrBlank()) {
put("p_current_pin", currentPin)
}
}
withJwtRefreshRetry {
postgrest.rpc("clear_profile_pin", params)
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear profile PIN", e)
Result.failure(e)
}
}
suspend fun verifyProfilePin(profileId: Int, pin: String): Result<SupabaseProfilePinVerifyResult> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_pin", pin)
}
val response = withJwtRefreshRetry {
postgrest.rpc("verify_profile_pin", params)
}
val decoded = response.decodeList<SupabaseProfilePinVerifyResult>().firstOrNull()
?: SupabaseProfilePinVerifyResult(unlocked = false, retryAfterSeconds = 0)
Result.success(decoded)
} catch (e: Exception) {
Log.e(TAG, "Failed to verify profile PIN", e)
Result.failure(e)
}
}
}

View file

@ -16,8 +16,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@ -31,12 +29,9 @@ class StartupSyncService @Inject constructor(
private val authManager: AuthManager,
private val pluginSyncService: PluginSyncService,
private val addonSyncService: AddonSyncService,
private val collectionSyncService: CollectionSyncService,
private val homeCatalogSettingsSyncService: HomeCatalogSettingsSyncService,
private val watchProgressSyncService: WatchProgressSyncService,
private val librarySyncService: LibrarySyncService,
private val watchedItemsSyncService: WatchedItemsSyncService,
private val profileSettingsSyncService: ProfileSettingsSyncService,
private val profileSyncService: ProfileSyncService,
private val pluginManager: PluginManager,
private val addonRepository: AddonRepositoryImpl,
@ -51,15 +46,10 @@ class StartupSyncService @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var startupPullJob: Job? = null
private var lastPulledKey: String? = null
private var lastPulledIncludedProfileSettings: Boolean = false
@Volatile
private var forceSyncRequested: Boolean = false
@Volatile
private var forceSyncIncludesProfileSettings: Boolean = true
@Volatile
private var pendingResyncKey: String? = null
@Volatile
private var pendingResyncIncludesProfileSettings: Boolean = false
init {
scope.launch {
@ -67,23 +57,15 @@ class StartupSyncService @Inject constructor(
when (state) {
is AuthState.FullAccount -> {
val force = forceSyncRequested
val includeProfileSettings = if (force) forceSyncIncludesProfileSettings else true
val started = scheduleStartupPull(
userId = state.userId,
force = force,
includeProfileSettings = includeProfileSettings
)
val started = scheduleStartupPull(state.userId, force = force)
if (force && started) forceSyncRequested = false
}
is AuthState.SignedOut -> {
startupPullJob?.cancel()
startupPullJob = null
lastPulledKey = null
lastPulledIncludedProfileSettings = false
forceSyncRequested = false
forceSyncIncludesProfileSettings = true
pendingResyncKey = null
pendingResyncIncludesProfileSettings = false
}
is AuthState.Loading -> Unit
}
@ -91,16 +73,11 @@ class StartupSyncService @Inject constructor(
}
}
fun requestSyncNow(includeProfileSettings: Boolean = true) {
fun requestSyncNow() {
forceSyncRequested = true
forceSyncIncludesProfileSettings = forceSyncIncludesProfileSettings || includeProfileSettings
when (val state = authManager.authState.value) {
is AuthState.FullAccount -> {
val started = scheduleStartupPull(
userId = state.userId,
force = true,
includeProfileSettings = includeProfileSettings
)
val started = scheduleStartupPull(state.userId, force = true)
if (started) forceSyncRequested = false
}
else -> Unit
@ -112,40 +89,28 @@ class StartupSyncService @Inject constructor(
return "${userId}_p${profileId}"
}
private fun scheduleStartupPull(
userId: String,
force: Boolean = false,
includeProfileSettings: Boolean = true
): Boolean {
private fun scheduleStartupPull(userId: String, force: Boolean = false): Boolean {
val key = pullKey(userId)
if (
!force &&
lastPulledKey == key &&
(!includeProfileSettings || lastPulledIncludedProfileSettings)
) {
return false
}
if (!force && lastPulledKey == key) return false
// Never cancel an active sync — it may be mid-write to DataStore.
// Instead, schedule a follow-up sync after the current one finishes.
if (startupPullJob?.isActive == true) {
if (force) {
pendingResyncKey = key
pendingResyncIncludesProfileSettings =
pendingResyncIncludesProfileSettings || includeProfileSettings
}
if (force) pendingResyncKey = key
return false
}
startupPullJob = scope.launch {
val maxAttempts = 3
var syncCompleted = false
for (attempt in 1..maxAttempts) {
val result = pullRemoteData(includeProfileSettings = includeProfileSettings)
repeat(maxAttempts) { index ->
val attempt = index + 1
Log.d(TAG, "Startup sync attempt $attempt/$maxAttempts for key=$key")
val result = pullRemoteData()
if (result.isSuccess) {
lastPulledKey = key
lastPulledIncludedProfileSettings = includeProfileSettings
Log.d(TAG, "Startup sync completed for key=$key")
syncCompleted = true
break
return@repeat
}
Log.w(TAG, "Startup sync attempt $attempt failed for key=$key", result.exceptionOrNull())
@ -156,26 +121,17 @@ class StartupSyncService @Inject constructor(
val resyncKey = pendingResyncKey
if (resyncKey != null) {
val resyncIncludesProfileSettings = pendingResyncIncludesProfileSettings
pendingResyncKey = null
pendingResyncIncludesProfileSettings = false
if (
!syncCompleted ||
resyncKey != lastPulledKey ||
(resyncIncludesProfileSettings && !lastPulledIncludedProfileSettings)
) {
scheduleStartupPull(
userId = userId,
force = true,
includeProfileSettings = resyncIncludesProfileSettings
)
if (!syncCompleted || resyncKey != lastPulledKey) {
Log.d(TAG, "Running pending re-sync for key=$resyncKey")
scheduleStartupPull(userId, force = true)
}
}
}
return true
}
private suspend fun pullRemoteData(includeProfileSettings: Boolean): Result<Unit> {
private suspend fun pullRemoteData(): Result<Unit> {
try {
val profileId = profileManager.activeProfileId.value
Log.d(TAG, "Pulling remote data for profile $profileId")
@ -184,94 +140,37 @@ class StartupSyncService @Inject constructor(
profileSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled profiles from remote")
if (includeProfileSettings) {
// Pull profile-scoped UI/player/settings blob.
// If not present, local settings are preserved.
profileSettingsSyncService.pullCurrentProfileFromRemote()
.onSuccess { applied ->
Log.d(TAG, "Profile settings blob pull completed for profile $profileId (applied=$applied)")
}
.onFailure { e ->
Log.e(TAG, "Failed to pull profile settings blob, keeping local settings", e)
}
pluginManager.isSyncingFromRemote = true
try {
val remotePluginUrls = pluginSyncService.getRemoteRepoUrls().getOrElse { throw it }
pluginManager.reconcileWithRemoteRepoUrls(
remoteUrls = remotePluginUrls,
removeMissingLocal = true
)
Log.d(TAG, "Pulled ${remotePluginUrls.size} plugin repos from remote for profile $profileId")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull plugins from remote, keeping local cache", e)
} finally {
pluginManager.isSyncingFromRemote = false
}
// Run independent syncs in parallel to reduce total startup time.
// Plugins, addons, collections, and home catalog settings don't depend on each other.
coroutineScope {
val pluginJob = async {
pluginManager.isSyncingFromRemote = true
try {
val remotePlugins = pluginSyncService.getRemoteRepoUrls().getOrElse { throw it }
pluginManager.reconcileWithRemoteRepoUrls(
remotePlugins = remotePlugins,
removeMissingLocal = true
)
Log.d(TAG, "Pulled ${remotePlugins.size} plugin repos from remote for profile $profileId")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull plugins from remote, keeping local cache", e)
} finally {
pluginManager.isSyncingFromRemote = false
pluginManager.flushPendingSync()
}
}
val addonJob = async {
addonRepository.isSyncingFromRemote = true
try {
val remoteAddonUrls = addonSyncService.getRemoteAddonUrls().getOrElse { throw it }
addonRepository.reconcileWithRemoteAddonUrls(
remoteUrls = remoteAddonUrls,
removeMissingLocal = true
)
Log.d(TAG, "Pulled ${remoteAddonUrls.size} addons from remote for profile $profileId")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull addons from remote, keeping local cache", e)
} finally {
addonRepository.isSyncingFromRemote = false
}
}
val collectionJob = async {
try {
collectionSyncService.pullFromRemote()
.onSuccess { applied ->
Log.d(TAG, "Collections pull completed for profile $profileId (applied=$applied)")
}
.onFailure { e ->
Log.e(TAG, "Failed to pull collections from remote, keeping local", e)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to pull collections from remote", e)
}
}
val homeCatalogJob = async {
try {
homeCatalogSettingsSyncService.pullFromRemote()
.onSuccess { applied ->
Log.d(TAG, "Home catalog settings pull completed for profile $profileId (applied=$applied)")
}
.onFailure { e ->
Log.e(TAG, "Failed to pull home catalog settings from remote, keeping local", e)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to pull home catalog settings from remote", e)
}
}
pluginJob.await()
addonJob.await()
collectionJob.await()
homeCatalogJob.await()
addonRepository.isSyncingFromRemote = true
try {
val remoteAddonUrls = addonSyncService.getRemoteAddonUrls().getOrElse { throw it }
addonRepository.reconcileWithRemoteAddonUrls(
remoteUrls = remoteAddonUrls,
removeMissingLocal = true
)
Log.d(TAG, "Pulled ${remoteAddonUrls.size} addons from remote for profile $profileId")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull addons from remote, keeping local cache", e)
} finally {
addonRepository.isSyncingFromRemote = false
}
val isTraktConnected = traktAuthDataStore.isEffectivelyAuthenticated.first()
val shouldUseSupabaseWatchProgressSync = watchProgressSyncService.shouldUseSupabaseWatchProgressSync()
Log.d(
TAG,
"Watch progress sync: isTraktConnected=$isTraktConnected shouldUseSupabaseWatchProgressSync=$shouldUseSupabaseWatchProgressSync"
)
val isPrimaryProfile = profileManager.activeProfileId.value == 1
val isTraktConnected = isPrimaryProfile && traktAuthDataStore.isAuthenticated.first()
Log.d(TAG, "Watch progress sync: isTraktConnected=$isTraktConnected isPrimaryProfile=$isPrimaryProfile")
if (!isTraktConnected) {
// Pull library and watched items first — these are lightweight and critical.
// Watch progress is pulled last because the table is large and may time out;
@ -312,29 +211,6 @@ class StartupSyncService @Inject constructor(
} finally {
watchProgressRepository.isSyncingFromRemote = false
}
} else if (shouldUseSupabaseWatchProgressSync) {
try {
val remoteWatchedItems = watchedItemsSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled ${remoteWatchedItems.size} watched items from remote")
watchedItemsPreferences.replaceWithRemoteItems(remoteWatchedItems)
watchProgressRepository.hasCompletedInitialWatchedItemsPull = true
Log.d(TAG, "Reconciled local watched items with ${remoteWatchedItems.size} remote items")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull watched items, continuing with Trakt library mode", e)
}
watchProgressRepository.isSyncingFromRemote = true
try {
val remoteEntries = watchProgressSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled ${remoteEntries.size} watch progress entries from remote")
watchProgressPreferences.mergeRemoteEntries(remoteEntries.toMap())
watchProgressRepository.hasCompletedInitialPull = true
Log.d(TAG, "Merged local watch progress with ${remoteEntries.size} remote entries")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull watch progress while Trakt is connected, continuing", e)
} finally {
watchProgressRepository.isSyncingFromRemote = false
}
} else {
Log.d(TAG, "Skipping watch progress & library sync (Trakt connected)")
}

View file

@ -4,8 +4,6 @@ import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.TraktAuthDataStore
import com.nuvio.tv.data.local.TraktSettingsDataStore
import com.nuvio.tv.data.local.WatchProgressSource
import com.nuvio.tv.data.local.WatchProgressPreferences
import com.nuvio.tv.data.remote.supabase.SupabaseWatchProgress
import com.nuvio.tv.domain.model.WatchProgress
@ -29,7 +27,6 @@ class WatchProgressSyncService @Inject constructor(
private val postgrest: Postgrest,
private val watchProgressPreferences: WatchProgressPreferences,
private val traktAuthDataStore: TraktAuthDataStore,
private val traktSettingsDataStore: TraktSettingsDataStore,
private val profileManager: ProfileManager
) {
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
@ -41,15 +38,10 @@ class WatchProgressSyncService @Inject constructor(
}
}
suspend fun shouldUseSupabaseWatchProgressSync(): Boolean {
val hasEffectiveTraktConnection = traktAuthDataStore.isEffectivelyAuthenticated.first()
val source = traktSettingsDataStore.watchProgressSource.first()
return !(hasEffectiveTraktConnection && source == WatchProgressSource.TRAKT)
}
suspend fun deleteFromRemote(keys: Collection<String>): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress delete")
return@withContext Result.success(Unit)
}
@ -85,15 +77,13 @@ class WatchProgressSyncService @Inject constructor(
*/
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watch progress push")
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress push")
return@withContext Result.success(Unit)
}
val rawEntries = watchProgressPreferences.getAllRawEntries()
val entries = canonicalizeForRemote(rawEntries).filterValues { progress ->
!(progress.position <= 1L && progress.duration <= 1L && progress.duration > 0L)
}
val entries = canonicalizeForRemote(rawEntries)
Log.d(TAG, "pushToRemote: ${rawEntries.size} local entries, ${entries.size} canonical entries to push")
entries.forEach { (key, progress) ->
Log.d(TAG, " push entry: key=$key contentId=${progress.contentId} type=${progress.contentType} pos=${progress.position} dur=${progress.duration} lastWatched=${progress.lastWatched}")
@ -133,8 +123,8 @@ class WatchProgressSyncService @Inject constructor(
suspend fun pushSingleToRemote(key: String, progress: WatchProgress): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping single watch progress push")
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping single watch progress push")
return@withContext Result.success(Unit)
}
@ -176,8 +166,8 @@ class WatchProgressSyncService @Inject constructor(
*/
suspend fun pullFromRemote(): Result<List<Pair<String, WatchProgress>>> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watch progress pull")
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress pull")
return@withContext Result.success(emptyList())
}
@ -286,11 +276,7 @@ class WatchProgressSyncService @Inject constructor(
}
}
.groupBy { it.contentId }
.mapValues { (_, episodes) -> episodes.maxWithOrNull(
compareBy<WatchProgress> { it.lastWatched }
.thenBy { it.season ?: 0 }
.thenBy { it.episode ?: 0 }
) }
.mapValues { (_, episodes) -> episodes.maxByOrNull { it.lastWatched } }
latestEpisodeByContent.forEach { (contentId, latestEpisode) ->
val latest = latestEpisode ?: return@forEach

View file

@ -4,8 +4,6 @@ import android.util.Log
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.TraktAuthDataStore
import com.nuvio.tv.data.local.TraktSettingsDataStore
import com.nuvio.tv.data.local.WatchProgressSource
import com.nuvio.tv.data.local.WatchedItemsPreferences
import com.nuvio.tv.data.remote.supabase.SupabaseWatchedItem
import com.nuvio.tv.domain.model.WatchedItem
@ -22,7 +20,6 @@ import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "WatchedItemsSyncService"
private const val WATCHED_ITEMS_PAGE_SIZE = 900
@Singleton
class WatchedItemsSyncService @Inject constructor(
@ -30,7 +27,6 @@ class WatchedItemsSyncService @Inject constructor(
private val postgrest: Postgrest,
private val watchedItemsPreferences: WatchedItemsPreferences,
private val traktAuthDataStore: TraktAuthDataStore,
private val traktSettingsDataStore: TraktSettingsDataStore,
private val profileManager: ProfileManager
) {
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
@ -42,16 +38,10 @@ class WatchedItemsSyncService @Inject constructor(
}
}
private suspend fun shouldUseSupabaseWatchProgressSync(): Boolean {
val hasEffectiveTraktConnection = traktAuthDataStore.isEffectivelyAuthenticated.first()
val source = traktSettingsDataStore.watchProgressSource.first()
return !(hasEffectiveTraktConnection && source == WatchProgressSource.TRAKT)
}
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watched items push")
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watched items push")
return@withContext Result.success(Unit)
}
@ -90,116 +80,35 @@ class WatchedItemsSyncService @Inject constructor(
suspend fun pullFromRemote(): Result<List<WatchedItem>> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watched items pull")
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watched items pull")
return@withContext Result.success(emptyList())
}
val profileId = profileManager.activeProfileId.value
val allItems = mutableListOf<WatchedItem>()
var page = 1
while (true) {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_page", page)
put("p_page_size", WATCHED_ITEMS_PAGE_SIZE)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_watched_items", params)
}
val remote = response.decodeList<SupabaseWatchedItem>()
Log.d(TAG, "pullFromRemote: page $page fetched ${remote.size} watched items for profile $profileId")
allItems.addAll(remote.map { entry ->
WatchedItem(
contentId = entry.contentId,
contentType = entry.contentType,
title = entry.title,
season = entry.season,
episode = entry.episode,
watchedAt = entry.watchedAt
)
})
if (remote.size < WATCHED_ITEMS_PAGE_SIZE) break
page++
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_watched_items", params)
}
val remote = response.decodeList<SupabaseWatchedItem>()
Log.d(TAG, "pullFromRemote: fetched ${allItems.size} total watched items from Supabase for profile $profileId")
Result.success(allItems)
Log.d(TAG, "pullFromRemote: fetched ${remote.size} watched items from Supabase for profile $profileId")
Result.success(remote.map { entry ->
WatchedItem(
contentId = entry.contentId,
contentType = entry.contentType,
title = entry.title,
season = entry.season,
episode = entry.episode,
watchedAt = entry.watchedAt
)
})
} catch (e: Exception) {
Log.e(TAG, "Failed to pull watched items from remote", e)
Result.failure(e)
}
}
suspend fun deleteFromRemote(
contentId: String,
season: Int?,
episode: Int?
): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
return@withContext Result.success(Unit)
}
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_keys", buildJsonArray {
addJsonObject {
put("content_id", contentId)
if (season != null) put("season", season)
if (episode != null) put("episode", episode)
}
})
}
withJwtRefreshRetry {
postgrest.rpc("sync_delete_watched_items", params)
}
Log.d(TAG, "Deleted watched item from remote: $contentId s=$season e=$episode for profile $profileId")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to delete watched item from remote", e)
Result.failure(e)
}
}
suspend fun deleteFromRemoteBatch(
contentId: String,
episodes: List<Pair<Int, Int>>
): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (!shouldUseSupabaseWatchProgressSync()) {
return@withContext Result.success(Unit)
}
if (episodes.isEmpty()) return@withContext Result.success(Unit)
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_keys", buildJsonArray {
episodes.forEach { (season, episode) ->
addJsonObject {
put("content_id", contentId)
put("season", season)
put("episode", episode)
}
}
})
}
withJwtRefreshRetry {
postgrest.rpc("sync_delete_watched_items", params)
}
Log.d(TAG, "Batch deleted ${episodes.size} watched items from remote for $contentId profile $profileId")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to batch delete watched items from remote", e)
Result.failure(e)
}
}
}

View file

@ -1,9 +1,7 @@
package com.nuvio.tv.core.tmdb
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.TmdbApi
import com.nuvio.tv.data.remote.api.TmdbDiscoverResult
import com.nuvio.tv.data.remote.api.TmdbEpisode
import com.nuvio.tv.data.remote.api.TmdbImage
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast
@ -15,21 +13,18 @@ import com.nuvio.tv.domain.model.MetaCompany
import com.nuvio.tv.domain.model.MetaPreview
import com.nuvio.tv.domain.model.PersonDetail
import com.nuvio.tv.domain.model.PosterShape
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.util.concurrent.ConcurrentHashMap
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "TmdbMetadataService"
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY
private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
@Singleton
class TmdbMetadataService @Inject constructor(
@ -38,13 +33,8 @@ class TmdbMetadataService @Inject constructor(
// In-memory caches
private val enrichmentCache = ConcurrentHashMap<String, TmdbEnrichment>()
private val episodeCache = ConcurrentHashMap<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
private val enrichmentInFlight = ConcurrentHashMap<String, CompletableDeferred<TmdbEnrichment?>>()
private val episodeInFlight = ConcurrentHashMap<String, CompletableDeferred<Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>>()
private val personCache = ConcurrentHashMap<String, PersonDetail>()
private val moreLikeThisCache = ConcurrentHashMap<String, List<MetaPreview>>()
private val entityHeaderCache = ConcurrentHashMap<String, TmdbEntityHeader>()
private val entityRailCache = ConcurrentHashMap<String, List<MetaPreview>>()
private val entityBrowseCache = ConcurrentHashMap<String, TmdbEntityBrowseData>()
suspend fun fetchEnrichment(
tmdbId: String,
@ -55,13 +45,8 @@ class TmdbMetadataService @Inject constructor(
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$tmdbId:${contentType.name}:$normalizedLanguage"
enrichmentCache[cacheKey]?.let { return@withContext it }
enrichmentInFlight[cacheKey]?.let { return@withContext it.await() }
val numericId = tmdbId.toIntOrNull() ?: return@withContext null
val requestDeferred = CompletableDeferred<TmdbEnrichment?>()
enrichmentInFlight.putIfAbsent(cacheKey, requestDeferred)?.let { existing ->
return@withContext existing.await()
}
val tmdbType = when (contentType) {
ContentType.SERIES, ContentType.TV -> "tv"
else -> "movie"
@ -121,11 +106,10 @@ class TmdbMetadataService @Inject constructor(
val description = details?.overview?.takeIf { it.isNotBlank() }
val releaseInfo = details?.releaseDate
?: details?.firstAirDate
val status = details?.status?.trim()?.takeIf { it.isNotBlank() }
val rating = details?.voteAverage
val runtime = details?.runtime ?: details?.episodeRunTime?.firstOrNull()
val countries = details?.productionCountries
?.mapNotNull { it.iso31661?.trim()?.uppercase()?.takeIf { code -> code.isNotBlank() } }
?.mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } }
?.takeIf { it.isNotEmpty() }
?: details?.originCountry?.takeIf { it.isNotEmpty() }
val language = details?.originalLanguage?.takeIf { it.isNotBlank() }
@ -136,8 +120,7 @@ class TmdbMetadataService @Inject constructor(
val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(company.logoPath, size = "w300"),
tmdbId = company.id
logo = buildImageUrl(company.logoPath, size = "w300")
)
}
val networks = details?.networks
@ -146,19 +129,22 @@ class TmdbMetadataService @Inject constructor(
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(network.logoPath, size = "w300"),
tmdbId = network.id
logo = buildImageUrl(network.logoPath, size = "w300")
)
}
val poster = buildImageUrl(details?.posterPath, size = "w500")
val backdrop = buildImageUrl(details?.backdropPath, size = "w1280")
val collectionId = details?.belongsToCollection?.id
val collectionName = details?.belongsToCollection?.name
val logoPath = images?.logos?.let {
selectBestLocalizedImagePath(it, normalizedLanguage)
}
val logoPath = images?.logos
?.sortedWith(
compareByDescending<com.nuvio.tv.data.remote.api.TmdbImage> {
it.iso6391 == normalizedLanguage.substringBefore("-")
}
.thenByDescending { it.iso6391 == "en" }
.thenByDescending { it.iso6391 == null }
)
?.firstOrNull()
?.filePath
val logo = buildImageUrl(logoPath, size = "w500")
@ -273,7 +259,7 @@ class TmdbMetadataService @Inject constructor(
genres.isEmpty() && description == null && backdrop == null && logo == null &&
poster == null && castMembers.isEmpty() && director.isEmpty() && writer.isEmpty() &&
releaseInfo == null && rating == null && runtime == null && countries.isNullOrEmpty() && language == null &&
productionCompanies.isEmpty() && networks.isEmpty() && ageRating == null && status == null
productionCompanies.isEmpty() && networks.isEmpty() && ageRating == null
) {
return@withContext null
}
@ -296,24 +282,14 @@ class TmdbMetadataService @Inject constructor(
productionCompanies = productionCompanies,
networks = networks,
ageRating = ageRating,
status = status,
countries = countries,
language = language,
collectionId = collectionId,
collectionName = collectionName
language = language
)
enrichmentCache[cacheKey] = enrichment
requestDeferred.complete(enrichment)
enrichment
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e)
requestDeferred.complete(null)
null
} finally {
enrichmentInFlight.remove(cacheKey, requestDeferred)
}
}
@ -325,41 +301,27 @@ class TmdbMetadataService @Inject constructor(
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$tmdbId:${seasonNumbers.sorted().joinToString(",")}:$normalizedLanguage"
episodeCache[cacheKey]?.let { return@withContext it }
episodeInFlight[cacheKey]?.let { return@withContext it.await() }
val numericId = tmdbId.toIntOrNull() ?: return@withContext emptyMap()
val requestDeferred = CompletableDeferred<Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
episodeInFlight.putIfAbsent(cacheKey, requestDeferred)?.let { existing ->
return@withContext existing.await()
}
val result = mutableMapOf<Pair<Int, Int>, TmdbEpisodeEnrichment>()
try {
seasonNumbers.distinct().forEach { season ->
try {
val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY, normalizedLanguage)
val episodes = response.body()?.episodes.orEmpty()
episodes.forEach { ep ->
val epNum = ep.episodeNumber ?: return@forEach
result[season to epNum] = ep.toEnrichment()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
seasonNumbers.distinct().forEach { season ->
try {
val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY, normalizedLanguage)
val episodes = response.body()?.episodes.orEmpty()
episodes.forEach { ep ->
val epNum = ep.episodeNumber ?: return@forEach
result[season to epNum] = ep.toEnrichment()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
}
val finalResult = result.toMap()
if (finalResult.isNotEmpty()) {
episodeCache[cacheKey] = finalResult
}
requestDeferred.complete(finalResult)
finalResult
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} finally {
episodeInFlight.remove(cacheKey, requestDeferred)
}
if (result.isNotEmpty()) {
episodeCache[cacheKey] = result
}
result
}
suspend fun fetchMoreLikeThis(
@ -444,20 +406,7 @@ class TmdbMetadataService @Inject constructor(
val backdrop = buildImageUrl(localizedBackdropPath ?: rec.backdropPath, size = "w1280")
val fallbackPoster = buildImageUrl(rec.posterPath, size = "w780")
val releaseInfo = if (recTmdbType == "tv") {
val startYear = rec.firstAirDate?.take(4)
if (startYear != null) {
val tvDetails = runCatching {
tmdbApi.getTvDetails(rec.id, TMDB_API_KEY, normalizedLanguage).body()
}.getOrNull()
val status = tvDetails?.status
val endYear = tvDetails?.lastAirDate?.take(4)
buildShowYearRange(startYear, endYear, status)
} else null
} else {
rec.releaseDate?.take(4)
}
val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4)
MetaPreview(
id = "tmdb:${rec.id}",
@ -484,385 +433,17 @@ class TmdbMetadataService @Inject constructor(
}
}
private val collectionCache = ConcurrentHashMap<String, List<MetaPreview>>()
suspend fun fetchMovieCollection(
collectionId: Int,
language: String = "en"
): List<MetaPreview> = withContext(Dispatchers.IO) {
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$collectionId:$normalizedLanguage:collection"
collectionCache[cacheKey]?.let { return@withContext it }
try {
val collectionResponse = tmdbApi.getCollectionDetails(collectionId, TMDB_API_KEY, normalizedLanguage).body()
val rawParts = collectionResponse?.parts.orEmpty()
// Show in release order
val sortedParts = rawParts.sortedBy { it.releaseDate ?: "9999" }
val includeImageLanguage = buildString {
append(normalizedLanguage.substringBefore("-"))
append(",")
append(normalizedLanguage)
append(",en,null")
}
val items = coroutineScope {
sortedParts.map { part ->
async {
val title = part.title ?: return@async null
val localizedBackdropPath = runCatching {
tmdbApi.getMovieImages(part.id, TMDB_API_KEY, includeImageLanguage).body()
}.getOrNull()?.let { images ->
selectBestLocalizedImagePath(
images = images.backdrops.orEmpty(),
normalizedLanguage = normalizedLanguage
)
}
val backdrop = buildImageUrl(localizedBackdropPath ?: part.backdropPath, size = "w1280")
val fallbackPoster = buildImageUrl(part.posterPath, size = "w780")
val releaseInfo = part.releaseDate?.take(4)
MetaPreview(
id = "tmdb:${part.id}",
type = ContentType.MOVIE,
name = title,
poster = backdrop ?: fallbackPoster,
posterShape = PosterShape.LANDSCAPE,
background = backdrop,
logo = null,
description = part.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo,
imdbRating = part.voteAverage?.toFloat(),
genres = emptyList()
)
}
}.awaitAll().filterNotNull()
}
collectionCache[cacheKey] = items
items
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch collection for $collectionId: ${e.message}")
emptyList()
}
}
suspend fun fetchEntityBrowse(
entityKind: TmdbEntityKind,
entityId: Int,
sourceType: String,
fallbackName: String? = null,
language: String = "en"
): TmdbEntityBrowseData? = withContext(Dispatchers.IO) {
val normalizedLanguage = normalizeTmdbLanguage(language)
val normalizedSourceType = normalizeEntitySourceType(sourceType)
val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$normalizedLanguage"
entityBrowseCache[cacheKey]?.let { return@withContext it }
val header = fetchEntityHeader(
entityKind = entityKind,
entityId = entityId,
fallbackName = fallbackName,
language = normalizedLanguage
)
val rails = buildEntityMediaOrder(entityKind, normalizedSourceType)
.flatMap { mediaType ->
TmdbEntityRailType.values().mapNotNull { railType ->
val pageResult = fetchEntityRailPage(
entityKind = entityKind,
entityId = entityId,
mediaType = mediaType,
railType = railType,
language = normalizedLanguage,
page = 1
)
val items = pageResult.items
if (items.isEmpty()) {
null
} else {
TmdbEntityRail(
mediaType = mediaType,
railType = railType,
items = items,
currentPage = 1,
hasMore = pageResult.hasMore,
isLoading = false
)
}
}
}
if (header == null && rails.isEmpty()) return@withContext null
val data = TmdbEntityBrowseData(
header = header ?: TmdbEntityHeader(
id = entityId,
kind = entityKind,
name = fallbackName?.takeIf { it.isNotBlank() } ?: "Unknown",
logo = null,
originCountry = null,
secondaryLabel = null,
description = null
),
rails = rails
)
entityBrowseCache[cacheKey] = data
data
}
private suspend fun fetchEntityHeader(
entityKind: TmdbEntityKind,
entityId: Int,
fallbackName: String?,
language: String
): TmdbEntityHeader? {
val cacheKey = "${entityKind.routeValue}:$entityId:$language:header"
entityHeaderCache[cacheKey]?.let { return it }
val header = try {
when (entityKind) {
TmdbEntityKind.COMPANY -> {
val body = tmdbApi.getCompanyDetails(entityId, TMDB_API_KEY).body()
if (body == null) {
null
} else {
TmdbEntityHeader(
id = body.id,
kind = entityKind,
name = body.name?.takeIf { it.isNotBlank() }
?: fallbackName?.takeIf { it.isNotBlank() }
?: "Unknown",
logo = buildImageUrl(body.logoPath, size = "w500"),
originCountry = body.originCountry?.takeIf { it.isNotBlank() },
secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() },
description = body.description?.takeIf { it.isNotBlank() }
)
}
}
TmdbEntityKind.NETWORK -> {
val body = tmdbApi.getNetworkDetails(entityId, TMDB_API_KEY).body()
if (body == null) {
null
} else {
TmdbEntityHeader(
id = body.id,
kind = entityKind,
name = body.name?.takeIf { it.isNotBlank() }
?: fallbackName?.takeIf { it.isNotBlank() }
?: "Unknown",
logo = buildImageUrl(body.logoPath, size = "w500"),
originCountry = body.originCountry?.takeIf { it.isNotBlank() },
secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() },
description = null
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch ${entityKind.routeValue} header for $entityId: ${e.message}")
null
} ?: fallbackName?.takeIf { it.isNotBlank() }?.let {
TmdbEntityHeader(
id = entityId,
kind = entityKind,
name = it,
logo = null,
originCountry = null,
secondaryLabel = null,
description = null
)
}
if (header != null) {
entityHeaderCache[cacheKey] = header
}
return header
}
suspend fun fetchEntityRailPage(
entityKind: TmdbEntityKind,
entityId: Int,
mediaType: TmdbEntityMediaType,
railType: TmdbEntityRailType,
language: String,
page: Int
): TmdbEntityRailPageResult {
if (entityKind == TmdbEntityKind.NETWORK && mediaType == TmdbEntityMediaType.MOVIE) {
return TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
}
val cacheKey = "${entityKind.routeValue}:$entityId:${mediaType.value}:${railType.value}:$language:page:$page"
entityRailCache[cacheKey]?.let { cached ->
return TmdbEntityRailPageResult(
items = cached,
hasMore = cached.isNotEmpty()
)
}
val today = LocalDate.now().toString()
val voteCountFloor = if (railType == TmdbEntityRailType.TOP_RATED) TOP_RATED_VOTE_COUNT_FLOOR else null
val result = try {
val response = when (mediaType) {
TmdbEntityMediaType.MOVIE -> {
tmdbApi.discoverMovies(
apiKey = TMDB_API_KEY,
language = language,
page = page,
sortBy = movieSortBy(railType),
withCompanies = entityId.toString(),
releaseDateLte = if (railType == TmdbEntityRailType.RECENT) today else null,
voteCountGte = voteCountFloor
).body()
}
TmdbEntityMediaType.TV -> {
tmdbApi.discoverTv(
apiKey = TMDB_API_KEY,
language = language,
page = page,
sortBy = tvSortBy(railType),
withCompanies = if (entityKind == TmdbEntityKind.COMPANY) entityId.toString() else null,
withNetworks = if (entityKind == TmdbEntityKind.NETWORK) entityId.toString() else null,
firstAirDateLte = if (railType == TmdbEntityRailType.RECENT) today else null,
voteCountGte = voteCountFloor
).body()
}
}
val results = response?.results.orEmpty()
val totalPages = response?.totalPages ?: page
val mappedItems = results
.filter { it.id > 0 }
.mapNotNull { discoverItem ->
mapEntityDiscoverResult(
result = discoverItem,
mediaType = mediaType
)
}
.take(ENTITY_RAIL_MAX_ITEMS)
TmdbEntityRailPageResult(
items = mappedItems,
hasMore = page < totalPages && mappedItems.isNotEmpty()
)
} catch (e: Exception) {
Log.w(
TAG,
"Failed to fetch ${entityKind.routeValue} rail ${railType.value}/${mediaType.value} for $entityId: ${e.message}"
)
TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
}
if (result.items.isNotEmpty()) {
entityRailCache[cacheKey] = result.items
}
return result
}
private fun mapEntityDiscoverResult(
result: TmdbDiscoverResult,
mediaType: TmdbEntityMediaType
): MetaPreview? {
val title = result.title?.takeIf { it.isNotBlank() }
?: result.name?.takeIf { it.isNotBlank() }
?: result.originalTitle?.takeIf { it.isNotBlank() }
?: result.originalName?.takeIf { it.isNotBlank() }
?: return null
val poster = buildImageUrl(result.posterPath, size = "w500")
?: buildImageUrl(result.backdropPath, size = "w780")
?: return null
val background = buildImageUrl(result.backdropPath, size = "w1280")
val releaseInfo = when (mediaType) {
TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4)
TmdbEntityMediaType.TV -> result.firstAirDate?.take(4)
}
return MetaPreview(
id = "tmdb:${result.id}",
type = if (mediaType == TmdbEntityMediaType.TV) ContentType.SERIES else ContentType.MOVIE,
name = title,
poster = poster,
posterShape = PosterShape.POSTER,
background = background,
logo = null,
description = result.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo,
imdbRating = result.voteAverage?.toFloat(),
genres = emptyList()
)
}
internal fun buildEntityMediaOrder(
entityKind: TmdbEntityKind,
sourceType: String
): List<TmdbEntityMediaType> {
if (entityKind == TmdbEntityKind.NETWORK) {
return listOf(TmdbEntityMediaType.TV)
}
return when (normalizeEntitySourceType(sourceType)) {
"movie" -> listOf(TmdbEntityMediaType.MOVIE, TmdbEntityMediaType.TV)
else -> listOf(TmdbEntityMediaType.TV, TmdbEntityMediaType.MOVIE)
}
}
private fun normalizeEntitySourceType(sourceType: String): String {
return when (sourceType.trim().lowercase(Locale.US)) {
"movie" -> "movie"
"tv", "series", "show" -> "tv"
else -> "tv"
}
}
private fun movieSortBy(railType: TmdbEntityRailType): String = when (railType) {
TmdbEntityRailType.POPULAR -> "popularity.desc"
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
TmdbEntityRailType.RECENT -> "primary_release_date.desc"
}
private fun tvSortBy(railType: TmdbEntityRailType): String = when (railType) {
TmdbEntityRailType.POPULAR -> "popularity.desc"
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
TmdbEntityRailType.RECENT -> "first_air_date.desc"
}
private fun buildShowYearRange(startYear: String, endYear: String?, status: String?): String {
val isEnded = status != null && status != "Returning Series" && status != "In Production"
return when {
isEnded && endYear != null && endYear != startYear -> "$startYear - $endYear"
isEnded -> startYear
else -> "$startYear - "
}
}
private fun buildImageUrl(path: String?, size: String): String? {
val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null
return "https://image.tmdb.org/t/p/$size$clean"
}
private fun normalizeTmdbLanguage(language: String?): String {
val raw = language
return language
?.trim()
?.takeIf { it.isNotBlank() }
?.replace('_', '-')
?: return "en"
// Normalize region code to uppercase (e.g. pt-br -> pt-BR)
val normalized = raw.split("-").let { parts ->
if (parts.size == 2) "${parts[0].lowercase(Locale.US)}-${parts[1].uppercase(Locale.US)}"
else raw.lowercase(Locale.US)
}
// Map codes unsupported by TMDB to their closest equivalent
return when (normalized) {
"es-419" -> "es-MX"
else -> normalized
}
?: "en"
}
private fun selectBestLocalizedImagePath(
@ -871,12 +452,9 @@ class TmdbMetadataService @Inject constructor(
): String? {
if (images.isEmpty()) return null
val languageCode = normalizedLanguage.substringBefore("-")
val regionCode = normalizedLanguage.substringAfter("-", "").uppercase(Locale.US).takeIf { it.length == 2 }
?: DEFAULT_LANGUAGE_REGIONS[languageCode]
return images
.sortedWith(
compareByDescending<TmdbImage> { it.iso6391 == languageCode && it.iso31661 == regionCode }
.thenByDescending { it.iso6391 == languageCode && it.iso31661 == null }
compareByDescending<TmdbImage> { it.iso6391 == normalizedLanguage }
.thenByDescending { it.iso6391 == languageCode }
.thenByDescending { it.iso6391 == "en" }
.thenByDescending { it.iso6391 == null }
@ -885,47 +463,27 @@ class TmdbMetadataService @Inject constructor(
?.filePath
}
companion object {
private val DEFAULT_LANGUAGE_REGIONS = mapOf(
"pt" to "PT",
"es" to "ES"
)
private const val ENTITY_RAIL_MAX_ITEMS = 20
private const val TOP_RATED_VOTE_COUNT_FLOOR = 200
}
suspend fun fetchPersonDetail(
personId: Int,
preferCrewCredits: Boolean? = null,
language: String = "en"
preferCrewCredits: Boolean? = null
): PersonDetail? =
withContext(Dispatchers.IO) {
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}:$normalizedLanguage"
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}"
personCache[cacheKey]?.let { return@withContext it }
try {
val (person, credits) = coroutineScope {
val personDeferred = async {
tmdbApi.getPersonDetails(personId, TMDB_API_KEY, normalizedLanguage).body()
tmdbApi.getPersonDetails(personId, TMDB_API_KEY).body()
}
val creditsDeferred = async {
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY, normalizedLanguage).body()
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY).body()
}
Pair(personDeferred.await(), creditsDeferred.await())
}
if (person == null) return@withContext null
// If biography is empty and language is not English, fetch English fallback
val biography = if (person.biography.isNullOrBlank() && normalizedLanguage != "en") {
runCatching {
tmdbApi.getPersonDetails(personId, TMDB_API_KEY, "en").body()?.biography
}.getOrNull()
} else {
person.biography
}?.takeIf { it.isNotBlank() }
val preferCrewFilmography = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment)
val castMovieCredits = mapMovieCreditsFromCast(credits?.cast.orEmpty())
@ -947,7 +505,7 @@ class TmdbMetadataService @Inject constructor(
val detail = PersonDetail(
tmdbId = person.id,
name = person.name ?: "Unknown",
biography = biography,
biography = person.biography?.takeIf { it.isNotBlank() },
birthday = person.birthday?.takeIf { it.isNotBlank() },
deathday = person.deathday?.takeIf { it.isNotBlank() },
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
@ -1141,11 +699,8 @@ data class TmdbEnrichment(
val productionCompanies: List<MetaCompany>,
val networks: List<MetaCompany>,
val ageRating: String?,
val status: String?,
val countries: List<String>?,
val language: String?,
val collectionId: Int?,
val collectionName: String?
val language: String?
)
data class TmdbEpisodeEnrichment(
@ -1156,58 +711,6 @@ data class TmdbEpisodeEnrichment(
val runtimeMinutes: Int?
)
enum class TmdbEntityKind(val routeValue: String) {
COMPANY("company"),
NETWORK("network");
companion object {
fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase(Locale.US)) {
"network" -> NETWORK
else -> COMPANY
}
}
}
enum class TmdbEntityMediaType(val value: String) {
MOVIE("movie"),
TV("tv")
}
enum class TmdbEntityRailType(val value: String) {
POPULAR("popular"),
TOP_RATED("top_rated"),
RECENT("recent")
}
data class TmdbEntityHeader(
val id: Int,
val kind: TmdbEntityKind,
val name: String,
val logo: String?,
val originCountry: String?,
val secondaryLabel: String?,
val description: String?
)
data class TmdbEntityRail(
val mediaType: TmdbEntityMediaType,
val railType: TmdbEntityRailType,
val items: List<MetaPreview>,
val currentPage: Int = 1,
val hasMore: Boolean = false,
val isLoading: Boolean = false
)
data class TmdbEntityBrowseData(
val header: TmdbEntityHeader,
val rails: List<TmdbEntityRail>
)
data class TmdbEntityRailPageResult(
val items: List<MetaPreview>,
val hasMore: Boolean
)
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
val title = name?.takeIf { it.isNotBlank() }
val overview = overview?.takeIf { it.isNotBlank() }

View file

@ -1,10 +1,7 @@
package com.nuvio.tv.core.tmdb
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.TmdbApi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -14,7 +11,7 @@ import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "TmdbService"
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY
private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
/**
* Service to handle TMDB ID conversions and lookups.
@ -29,9 +26,6 @@ class TmdbService @Inject constructor(
// Cache: TMDB ID -> IMDB ID
private val tmdbToImdbCache = ConcurrentHashMap<Int, String>()
private val imdbToTmdbInFlight = ConcurrentHashMap<String, CompletableDeferred<Int?>>()
private val tmdbToImdbInFlight = ConcurrentHashMap<String, CompletableDeferred<String?>>()
// Mutex for thread-safe cache operations
private val cacheMutex = Mutex()
@ -56,13 +50,6 @@ class TmdbService @Inject constructor(
return@withContext cached
}
val normalizedType = normalizeMediaType(mediaType)
val requestKey = "$imdbId:$normalizedType"
val requestDeferred = CompletableDeferred<Int?>()
imdbToTmdbInFlight.putIfAbsent(requestKey, requestDeferred)?.let { existing ->
return@withContext existing.await()
}
try {
Log.d(TAG, "Looking up TMDB ID for IMDB: $imdbId (type: $mediaType)")
@ -74,17 +61,13 @@ class TmdbService @Inject constructor(
if (!response.isSuccessful) {
Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
requestDeferred.complete(null)
return@withContext null
}
val body = response.body()
if (body == null) {
requestDeferred.complete(null)
return@withContext null
}
val body = response.body() ?: return@withContext null
// Determine which results to use based on media type
val normalizedType = normalizeMediaType(mediaType)
val result = when (normalizedType) {
"movie" -> body.movieResults?.firstOrNull()
"tv", "series" -> body.tvResults?.firstOrNull()
@ -99,25 +82,16 @@ class TmdbService @Inject constructor(
imdbToTmdbCache[imdbId] = found.id
tmdbToImdbCache[found.id] = imdbId
}
requestDeferred.complete(found.id)
return@withContext found.id
}
Log.w(TAG, "No TMDB result found for IMDB: $imdbId")
requestDeferred.complete(null)
null
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) {
Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e)
requestDeferred.complete(null)
null
} finally {
imdbToTmdbInFlight.remove(requestKey, requestDeferred)
}
}
@ -135,16 +109,10 @@ class TmdbService @Inject constructor(
return@withContext cached
}
val normalizedType = normalizeMediaType(mediaType)
val requestKey = "$tmdbId:$normalizedType"
val requestDeferred = CompletableDeferred<String?>()
tmdbToImdbInFlight.putIfAbsent(requestKey, requestDeferred)?.let { existing ->
return@withContext existing.await()
}
try {
Log.d(TAG, "Looking up IMDB ID for TMDB: $tmdbId (type: $mediaType)")
val normalizedType = normalizeMediaType(mediaType)
val response = when (normalizedType) {
"movie" -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY)
"tv", "series" -> tmdbApi.getTvExternalIds(tmdbId, TMDB_API_KEY)
@ -153,15 +121,10 @@ class TmdbService @Inject constructor(
if (!response.isSuccessful) {
Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
requestDeferred.complete(null)
return@withContext null
}
val body = response.body()
if (body == null) {
requestDeferred.complete(null)
return@withContext null
}
val body = response.body() ?: return@withContext null
body.imdbId?.let { imdbId ->
Log.d(TAG, "Found IMDB ID: $imdbId for TMDB: $tmdbId")
@ -171,25 +134,16 @@ class TmdbService @Inject constructor(
tmdbToImdbCache[tmdbId] = imdbId
imdbToTmdbCache[imdbId] = tmdbId
}
requestDeferred.complete(imdbId)
return@withContext imdbId
}
Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId")
requestDeferred.complete(null)
null
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) {
Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e)
requestDeferred.complete(null)
null
} finally {
tmdbToImdbInFlight.remove(requestKey, requestDeferred)
}
}
@ -248,8 +202,6 @@ class TmdbService @Inject constructor(
fun clearCache() {
imdbToTmdbCache.clear()
tmdbToImdbCache.clear()
imdbToTmdbInFlight.clear()
tmdbToImdbInFlight.clear()
Log.d(TAG, "Cache cleared")
}
@ -260,9 +212,4 @@ class TmdbService @Inject constructor(
imdbToTmdbCache[imdbId] = tmdbId
tmdbToImdbCache[tmdbId] = imdbId
}
/** Returns the cached TMDB ID for an IMDB ID without making any network call. */
fun cachedTmdbId(imdbId: String): Int? = imdbToTmdbCache[imdbId]
fun apiKey(): String = TMDB_API_KEY
}

View file

@ -1,147 +0,0 @@
package com.nuvio.tv.core.torrent
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
data class TorrServerFile(
val id: Int,
val path: String,
val length: Long
)
data class TorrServerStats(
val downloadSpeed: Long,
val uploadSpeed: Long,
val peers: Int,
val seeds: Int,
val preloadedBytes: Long,
val loadedSize: Long,
val torrentSize: Long,
val files: List<TorrServerFile>
)
@Singleton
class TorrServerApi @Inject constructor(
private val binary: TorrServerBinary
) {
companion object {
private const val TAG = "TorrServerApi"
private val JSON_TYPE = "application/json".toMediaType()
}
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val baseUrl: String get() = binary.baseUrl
suspend fun addTorrent(magnetLink: String, title: String? = null): String? = withContext(Dispatchers.IO) {
val body = JSONObject().apply {
put("action", "add")
put("link", magnetLink)
put("save_to_db", false)
if (title != null) put("title", title)
}
val request = Request.Builder()
.url("$baseUrl/torrents")
.post(body.toString().toRequestBody(JSON_TYPE))
.build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.e(TAG, "addTorrent failed: ${response.code}")
return@withContext null
}
val json = JSONObject(response.body?.string() ?: "{}")
val hash = json.optString("hash", "")
Log.d(TAG, "Torrent added: $hash")
hash.ifEmpty { null }
}
} catch (e: Exception) {
Log.e(TAG, "addTorrent error", e)
null
}
}
suspend fun getTorrentStats(hash: String): TorrServerStats? = withContext(Dispatchers.IO) {
val body = JSONObject().apply {
put("action", "get")
put("hash", hash)
}
val request = Request.Builder()
.url("$baseUrl/torrents")
.post(body.toString().toRequestBody(JSON_TYPE))
.build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val json = JSONObject(response.body?.string() ?: "{}")
val files = mutableListOf<TorrServerFile>()
val fileList = json.optJSONArray("file_stats") ?: JSONArray()
for (i in 0 until fileList.length()) {
val f = fileList.getJSONObject(i)
files.add(TorrServerFile(
id = f.optInt("id", i + 1),
path = f.optString("path", ""),
length = f.optLong("length", 0)
))
}
TorrServerStats(
downloadSpeed = json.optLong("download_speed", 0),
uploadSpeed = json.optLong("upload_speed", 0),
peers = json.optInt("active_peers", 0),
seeds = json.optInt("connected_seeders", 0),
preloadedBytes = json.optLong("preloaded_bytes", 0),
loadedSize = json.optLong("loaded_size", 0),
torrentSize = json.optLong("torrent_size", 0),
files = files
)
}
} catch (e: Exception) {
Log.w(TAG, "getTorrentStats error", e)
null
}
}
suspend fun dropTorrent(hash: String) = withContext(Dispatchers.IO) {
val body = JSONObject().apply {
put("action", "drop")
put("hash", hash)
}
val request = Request.Builder()
.url("$baseUrl/torrents")
.post(body.toString().toRequestBody(JSON_TYPE))
.build()
try {
client.newCall(request).execute().close()
Log.d(TAG, "Torrent dropped: $hash")
} catch (e: Exception) {
Log.w(TAG, "dropTorrent error", e)
}
}
fun getStreamUrl(magnetLink: String, fileIdx: Int): String {
val encodedLink = URLEncoder.encode(magnetLink, "UTF-8")
return "$baseUrl/stream?link=$encodedLink&index=$fileIdx&play"
}
}

View file

@ -1,165 +0,0 @@
package com.nuvio.tv.core.torrent
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages the TorrServer binary lifecycle.
* The binary is bundled in jniLibs/ as libtorrserver.so and installed
* to nativeLibraryDir by the Android package manager.
*/
@Singleton
class TorrServerBinary @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val TAG = "TorrServerBinary"
const val PORT = 8091
private const val STARTUP_TIMEOUT_MS = 15_000L
private const val HEALTH_CHECK_INTERVAL_MS = 200L
}
private var process: Process? = null
private val healthClient = OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
val baseUrl: String get() = "http://127.0.0.1:$PORT"
private val binaryFile: File
get() = File(context.applicationInfo.nativeLibraryDir, "libtorrserver.so")
private val configDir: File
get() = File(context.filesDir, "torrserver").also { it.mkdirs() }
val isBinaryAvailable: Boolean
get() = binaryFile.exists()
fun isRunning(): Boolean {
return try {
val request = Request.Builder().url("$baseUrl/echo").build()
healthClient.newCall(request).execute().use { it.isSuccessful }
} catch (e: Exception) {
false
}
}
suspend fun start() = withContext(Dispatchers.IO) {
if (isRunning()) {
Log.d(TAG, "TorrServer already running")
return@withContext
}
// Kill any orphaned TorrServer process that may be holding the port
// (e.g., from a previous app session that was force-killed).
killOrphanedProcess()
if (!isBinaryAvailable) {
throw TorrentException("TorrServer binary not found at ${binaryFile.absolutePath}")
}
if (!binaryFile.canExecute()) {
binaryFile.setExecutable(true)
}
val pb = ProcessBuilder(
binaryFile.absolutePath,
"--port", PORT.toString(),
"--path", configDir.absolutePath
)
pb.directory(configDir)
pb.redirectErrorStream(true)
Log.d(TAG, "Starting TorrServer on port $PORT from ${binaryFile.absolutePath}")
process = pb.start()
// Read stdout in daemon thread for debugging
val proc = process!!
Thread {
try {
proc.inputStream.bufferedReader().forEachLine { line ->
Log.d(TAG, "[server] $line")
}
} catch (_: Exception) {}
}.apply {
isDaemon = true
start()
}
// Wait for health check — also detect early process death so we
// don't spin for 15s if the binary crashed on launch.
val deadline = System.currentTimeMillis() + STARTUP_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (isRunning()) {
Log.d(TAG, "TorrServer started successfully")
return@withContext
}
if (!isProcessAlive(process)) {
val exitCode = process?.exitValue() ?: -1
process = null
throw TorrentException("TorrServer process died on startup (exit code $exitCode)")
}
delay(HEALTH_CHECK_INTERVAL_MS)
}
stop()
throw TorrentException("TorrServer failed to start within ${STARTUP_TIMEOUT_MS / 1000}s")
}
private fun killOrphanedProcess() {
try {
// Try graceful shutdown in case an old instance is still responding
val request = Request.Builder().url("$baseUrl/shutdown").build()
healthClient.newCall(request).execute().close()
Thread.sleep(1000)
Log.d(TAG, "Shut down orphaned TorrServer instance")
} catch (_: Exception) {
// No orphan responding — nothing to do
}
}
fun stop() {
// Try graceful shutdown
try {
val request = Request.Builder().url("$baseUrl/shutdown").build()
healthClient.newCall(request).execute().close()
} catch (_: Exception) {}
// Force kill after 3 seconds
process?.let { proc ->
try {
Thread.sleep(3000)
if (isProcessAlive(proc)) {
proc.destroyForcibly()
}
} catch (_: Exception) {
proc.destroyForcibly()
}
}
process = null
Log.d(TAG, "TorrServer stopped")
}
private fun isProcessAlive(proc: Process?): Boolean {
if (proc == null) return false
return try {
proc.exitValue()
false
} catch (_: IllegalThreadStateException) {
true
} catch (_: Exception) {
false
}
}
}

View file

@ -1,3 +0,0 @@
package com.nuvio.tv.core.torrent
class TorrentException(message: String) : Exception(message)

View file

@ -1,213 +0,0 @@
package com.nuvio.tv.core.torrent
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
private val VIDEO_EXTENSIONS = setOf("mkv", "mp4", "avi", "webm", "ts", "m4v", "mov", "wmv", "flv")
@Singleton
class TorrentService @Inject constructor(
private val binary: TorrServerBinary,
private val api: TorrServerApi
) {
companion object {
private const val TAG = "TorrentService"
private val DEFAULT_TRACKERS = listOf(
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://exodus.desync.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce"
)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow<TorrentState>(TorrentState.Idle)
val state: StateFlow<TorrentState> = _state.asStateFlow()
private var statsJob: Job? = null
private var currentHash: String? = null
/**
* Starts streaming a torrent. Returns the local HTTP URL for ExoPlayer.
*/
suspend fun startStream(
infoHash: String,
fileIdx: Int?,
filename: String? = null,
trackers: List<String> = emptyList()
): String = withContext(Dispatchers.IO) {
stopStream()
_state.value = TorrentState.Connecting
// Ensure binary is running
binary.start()
val magnetLink = buildMagnetUri(infoHash, trackers)
Log.d(TAG, "Starting stream: $magnetLink")
// Add torrent
val hash = api.addTorrent(magnetLink)
?: throw TorrentException("Failed to add torrent")
currentHash = hash
// Resolve file index
val resolvedIdx = resolveFileIndex(hash, fileIdx, filename)
// Get stream URL — TorrServer handles all buffering/piece management
val streamUrl = api.getStreamUrl(magnetLink, resolvedIdx)
Log.d(TAG, "Stream URL: $streamUrl")
// Start stats polling
startStatsPolling(hash)
_state.value = TorrentState.Streaming(
localUrl = streamUrl,
downloadSpeed = 0,
uploadSpeed = 0,
peers = 0,
seeds = 0,
bufferProgress = 0f,
totalProgress = 0f
)
streamUrl
}
fun stopStream() {
statsJob?.cancel()
statsJob = null
currentHash?.let { hash ->
try {
runBlocking(Dispatchers.IO) {
api.dropTorrent(hash)
}
} catch (e: Exception) {
Log.w(TAG, "Error dropping torrent", e)
}
}
currentHash = null
_state.value = TorrentState.Idle
}
fun shutdown() {
stopStream()
binary.stop()
}
private fun buildMagnetUri(infoHash: String, extraTrackers: List<String>): String {
val trackers = (DEFAULT_TRACKERS + extraTrackers).distinct()
val trackerParams = trackers.joinToString("") { "&tr=$it" }
return "magnet:?xt=urn:btih:$infoHash$trackerParams"
}
private suspend fun resolveFileIndex(hash: String, requestedIdx: Int?, filename: String?): Int {
// Poll for metadata — magnet links may not have it immediately
val deadline = System.currentTimeMillis() + 15_000L
var files: List<TorrServerFile> = emptyList()
while (System.currentTimeMillis() < deadline) {
files = api.getTorrentStats(hash)?.files ?: emptyList()
if (files.isNotEmpty()) break
Log.d(TAG, "Waiting for torrent metadata...")
delay(1_000L)
}
if (files.isEmpty()) {
Log.w(TAG, "No files after metadata timeout, guessing index ${requestedIdx?.plus(1) ?: 1}")
return requestedIdx?.plus(1) ?: 1
}
Log.d(TAG, "Torrent has ${files.size} files")
// Strategy 1: Match by filename (most reliable for season packs)
if (!filename.isNullOrBlank()) {
val name = filename.trim()
// Exact basename match
val exact = files.firstOrNull { f ->
f.path.substringAfterLast('/').equals(name, ignoreCase = true)
}
if (exact != null) {
Log.d(TAG, "File resolved by exact filename match: ${exact.path} -> id=${exact.id}")
return exact.id
}
// Contains match (addon filename may be substring of full path)
val contains = files.firstOrNull { f ->
f.path.contains(name, ignoreCase = true)
}
if (contains != null) {
Log.d(TAG, "File resolved by filename contains match: ${contains.path} -> id=${contains.id}")
return contains.id
}
}
// Strategy 2: Match by ID offset (requestedIdx + 1)
if (requestedIdx != null) {
val tsIdx = requestedIdx + 1
if (files.any { it.id == tsIdx }) {
Log.d(TAG, "File resolved by ID offset: id=$tsIdx")
return tsIdx
}
}
// Strategy 3: Positional index (handles TorrServer alphabetical sort mismatch)
if (requestedIdx != null && requestedIdx in files.indices) {
val positionalFile = files[requestedIdx]
Log.d(TAG, "File resolved by positional index: [$requestedIdx] -> ${positionalFile.path} (id=${positionalFile.id})")
return positionalFile.id
}
// Strategy 4: Fallback to largest video file
val videoFile = files
.filter { f ->
val ext = f.path.substringAfterLast('.', "").lowercase()
ext in VIDEO_EXTENSIONS
}
.maxByOrNull { it.length }
val result = videoFile?.id ?: files.maxByOrNull { it.length }?.id ?: 1
Log.d(TAG, "File resolved by largest video fallback: id=$result")
return result
}
private fun startStatsPolling(hash: String) {
statsJob?.cancel()
statsJob = scope.launch {
while (isActive) {
try {
val stats = api.getTorrentStats(hash)
val currentState = _state.value
if (stats != null && currentState is TorrentState.Streaming) {
_state.value = currentState.copy(
downloadSpeed = stats.downloadSpeed,
uploadSpeed = stats.uploadSpeed,
peers = stats.peers,
seeds = stats.seeds,
preloadedBytes = stats.preloadedBytes
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Stats polling error", e)
}
delay(1000)
}
}
}
}

View file

@ -1,62 +0,0 @@
package com.nuvio.tv.core.torrent
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
private val Context.torrentDataStore by preferencesDataStore(name = "torrent_settings")
data class TorrentSettingsData(
val p2pEnabled: Boolean = false,
val enableUpload: Boolean = true,
val hideTorrentStats: Boolean = true
)
@Singleton
class TorrentSettings @Inject constructor(
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private object Keys {
val P2P_ENABLED = booleanPreferencesKey("p2p_enabled")
val ENABLE_UPLOAD = booleanPreferencesKey("enable_upload")
val HIDE_TORRENT_STATS = booleanPreferencesKey("hide_torrent_stats")
}
val settings: Flow<TorrentSettingsData> = context.torrentDataStore.data.map { prefs ->
TorrentSettingsData(
p2pEnabled = prefs[Keys.P2P_ENABLED] ?: false,
enableUpload = prefs[Keys.ENABLE_UPLOAD] ?: true,
hideTorrentStats = prefs[Keys.HIDE_TORRENT_STATS] ?: true
)
}
fun setP2pEnabled(enabled: Boolean) {
scope.launch {
context.torrentDataStore.edit { it[Keys.P2P_ENABLED] = enabled }
}
}
fun setEnableUpload(enabled: Boolean) {
scope.launch {
context.torrentDataStore.edit { it[Keys.ENABLE_UPLOAD] = enabled }
}
}
fun setHideTorrentStats(enabled: Boolean) {
scope.launch {
context.torrentDataStore.edit { it[Keys.HIDE_TORRENT_STATS] = enabled }
}
}
}

View file

@ -1,22 +0,0 @@
package com.nuvio.tv.core.torrent
import androidx.compose.runtime.Immutable
@Immutable
sealed class TorrentState {
data object Idle : TorrentState()
data object Connecting : TorrentState()
data class Streaming(
val localUrl: String,
val downloadSpeed: Long,
val uploadSpeed: Long,
val peers: Int,
val seeds: Int,
val bufferProgress: Float,
val totalProgress: Float,
val preloadedBytes: Long = 0L
) : TorrentState()
data class Error(val message: String) : TorrentState()
}

View file

@ -1,40 +0,0 @@
package com.nuvio.tv.core.util
import com.nuvio.tv.domain.model.CatalogRow
import com.nuvio.tv.domain.model.MetaPreview
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
private val YEAR_REGEX = Regex("""\b(19|20)\d{2}\b""")
private val ISO_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE
fun MetaPreview.isUnreleased(today: LocalDate): Boolean {
released?.trim()?.takeIf { it.isNotEmpty() }?.let { rawReleased ->
val releaseDate = rawReleased.substringBefore('T')
try {
val date = LocalDate.parse(releaseDate, ISO_DATE_FORMATTER)
return date.isAfter(today)
} catch (_: DateTimeParseException) {
}
}
val info = releaseInfo ?: return false
// Try full date parse first (e.g. "2026-06-15")
try {
val date = LocalDate.parse(info.trim(), ISO_DATE_FORMATTER)
return date.isAfter(today)
} catch (_: DateTimeParseException) {
// fall through to year-only
}
// Fall back to year extraction
val yearStr = YEAR_REGEX.find(info)?.value ?: return false
val year = yearStr.toIntOrNull() ?: return false
return year > today.year
}
fun CatalogRow.filterReleasedItems(today: LocalDate): CatalogRow {
val filtered = items.filterNot { it.isUnreleased(today) }
return if (filtered.size == items.size) this else copy(items = filtered)
}

View file

@ -46,20 +46,15 @@ class AddonPreferences @Inject constructor(
private val gson = Gson()
private val orderedUrlsKey = stringPreferencesKey("installed_addon_urls_ordered")
private val legacyUrlsKey = stringSetPreferencesKey("installed_addon_urls")
private val userSetNamesKey = stringPreferencesKey("addon_user_set_names")
private val manifestSuffix = "/manifest.json"
private fun canonicalizeUrl(url: String): String {
val trimmed = url.trim().trimEnd('/')
val queryStart = trimmed.indexOf('?')
val path = if (queryStart >= 0) trimmed.substring(0, queryStart) else trimmed
val query = if (queryStart >= 0) trimmed.substring(queryStart) else ""
val cleanPath = if (path.endsWith(manifestSuffix, ignoreCase = true)) {
path.dropLast(manifestSuffix.length).trimEnd('/')
return if (trimmed.endsWith(manifestSuffix, ignoreCase = true)) {
trimmed.dropLast(manifestSuffix.length).trimEnd('/')
} else {
path.trimEnd('/')
trimmed
}
return cleanPath + query
}
val installedAddonUrls: Flow<List<String>> = effectiveProfileIdFlow.flatMapLatest { pid ->
@ -138,28 +133,6 @@ class AddonPreferences @Inject constructor(
}
}
val userSetNames: Flow<Map<String, String>> = effectiveProfileIdFlow.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences ->
val json = preferences[userSetNamesKey]
if (json != null) parseNameMap(json) else emptyMap()
}
}
suspend fun setUserSetNames(names: Map<String, String>) {
store().edit { preferences ->
preferences[userSetNamesKey] = gson.toJson(names)
}
}
private fun parseNameMap(json: String): Map<String, String> {
return try {
val type = object : TypeToken<Map<String, String>>() {}.type
gson.fromJson(json, type) ?: emptyMap()
} catch (e: Exception) {
emptyMap()
}
}
private fun getDefaultAddons(): Set<String> = setOf(
"https://v3-cinemeta.strem.io",
"https://opensubtitles-v3.strem.io"

View file

@ -1,42 +0,0 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AnimeSkipSettingsDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "animeskip_settings"
}
private fun store() = factory.get(profileManager.activeProfileId.value, FEATURE)
private val enabledKey = booleanPreferencesKey("animeskip_enabled")
private val clientIdKey = stringPreferencesKey("animeskip_client_id")
val enabled: Flow<Boolean> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { it[enabledKey] ?: false }
}
val clientId: Flow<String> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { it[clientIdKey] ?: "" }
}
suspend fun setEnabled(enabled: Boolean) {
store().edit { it[enabledKey] = enabled }
}
suspend fun setClientId(clientId: String) {
store().edit { it[clientIdKey] = clientId.trim() }
}
}

View file

@ -1,108 +0,0 @@
package com.nuvio.tv.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.authSessionNoticeDataStore: DataStore<Preferences> by preferencesDataStore(
name = "auth_session_notice_store"
)
enum class StartupAuthNotice {
NUVIO,
TRAKT
}
@Singleton
class AuthSessionNoticeDataStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val hadNuvioAuthKey = booleanPreferencesKey("had_nuvio_auth")
private val nuvioExplicitLogoutKey = booleanPreferencesKey("nuvio_explicit_logout")
private val pendingNuvioNoticeKey = booleanPreferencesKey("pending_nuvio_notice")
private val hadTraktAuthKey = booleanPreferencesKey("had_trakt_auth")
private val traktExplicitLogoutKey = booleanPreferencesKey("trakt_explicit_logout")
private val pendingTraktNoticeKey = booleanPreferencesKey("pending_trakt_notice")
val pendingNotice: Flow<StartupAuthNotice?> = context.authSessionNoticeDataStore.data.map { preferences ->
when {
preferences[pendingNuvioNoticeKey] == true -> StartupAuthNotice.NUVIO
preferences[pendingTraktNoticeKey] == true -> StartupAuthNotice.TRAKT
else -> null
}
}
suspend fun markNuvioAuthenticated() {
context.authSessionNoticeDataStore.edit { preferences ->
preferences[hadNuvioAuthKey] = true
preferences[nuvioExplicitLogoutKey] = false
preferences[pendingNuvioNoticeKey] = false
}
}
suspend fun markNuvioExplicitLogout() {
context.authSessionNoticeDataStore.edit { preferences ->
preferences[hadNuvioAuthKey] = false
preferences[nuvioExplicitLogoutKey] = true
preferences[pendingNuvioNoticeKey] = false
}
}
suspend fun markUnexpectedNuvioLogoutIfNeeded() {
context.authSessionNoticeDataStore.edit { preferences ->
val hadAuth = preferences[hadNuvioAuthKey] == true
val explicitLogout = preferences[nuvioExplicitLogoutKey] == true
if (hadAuth && !explicitLogout) {
preferences[pendingNuvioNoticeKey] = true
}
preferences[hadNuvioAuthKey] = false
preferences[nuvioExplicitLogoutKey] = false
}
}
suspend fun markTraktAuthenticated() {
context.authSessionNoticeDataStore.edit { preferences ->
preferences[hadTraktAuthKey] = true
preferences[traktExplicitLogoutKey] = false
preferences[pendingTraktNoticeKey] = false
}
}
suspend fun markTraktExplicitLogout() {
context.authSessionNoticeDataStore.edit { preferences ->
preferences[hadTraktAuthKey] = false
preferences[traktExplicitLogoutKey] = true
preferences[pendingTraktNoticeKey] = false
}
}
suspend fun markUnexpectedTraktLogoutIfNeeded() {
context.authSessionNoticeDataStore.edit { preferences ->
val hadAuth = preferences[hadTraktAuthKey] == true
val explicitLogout = preferences[traktExplicitLogoutKey] == true
if (hadAuth && !explicitLogout) {
preferences[pendingTraktNoticeKey] = true
}
preferences[hadTraktAuthKey] = false
preferences[traktExplicitLogoutKey] = false
}
}
suspend fun consumeNotice(notice: StartupAuthNotice) {
context.authSessionNoticeDataStore.edit { preferences ->
when (notice) {
StartupAuthNotice.NUVIO -> preferences[pendingNuvioNoticeKey] = false
StartupAuthNotice.TRAKT -> preferences[pendingTraktNoticeKey] = false
}
}
}
}

View file

@ -1,262 +0,0 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.domain.model.Collection
import com.nuvio.tv.domain.model.CollectionCatalogSource
import com.nuvio.tv.domain.model.CollectionFolder
import com.nuvio.tv.domain.model.FolderViewMode
import com.nuvio.tv.domain.model.PosterShape
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
data class ValidationResult(
val valid: Boolean,
val error: String? = null,
val collectionCount: Int = 0,
val folderCount: Int = 0
)
@Singleton
class CollectionsDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "collections"
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
factory.get(profileId, FEATURE)
private val gson = Gson()
private val collectionsKey = stringPreferencesKey("collections_json")
val collections: Flow<List<Collection>> =
profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
parseCollections(prefs[collectionsKey])
}
}
suspend fun setCollections(collections: List<Collection>) {
store().edit { prefs ->
if (collections.isEmpty()) {
prefs.remove(collectionsKey)
} else {
prefs[collectionsKey] = gson.toJson(collections.map { it.toSerializable() })
}
}
}
suspend fun addCollection(collection: Collection) {
store().edit { prefs ->
val current = parseCollections(prefs[collectionsKey]).toMutableList()
current.add(collection)
prefs[collectionsKey] = gson.toJson(current.map { it.toSerializable() })
}
}
suspend fun updateCollection(collection: Collection) {
store().edit { prefs ->
val current = parseCollections(prefs[collectionsKey]).toMutableList()
val index = current.indexOfFirst { it.id == collection.id }
if (index >= 0) {
current[index] = collection
}
prefs[collectionsKey] = gson.toJson(current.map { it.toSerializable() })
}
}
suspend fun removeCollection(collectionId: String) {
store().edit { prefs ->
val current = parseCollections(prefs[collectionsKey]).toMutableList()
current.removeAll { it.id == collectionId }
if (current.isEmpty()) {
prefs.remove(collectionsKey)
} else {
prefs[collectionsKey] = gson.toJson(current.map { it.toSerializable() })
}
}
}
fun generateId(): String = UUID.randomUUID().toString()
fun exportToJson(collections: List<Collection>): String {
return gson.toJson(collections.map { it.toSerializable() })
}
fun importFromJson(json: String): List<Collection> {
return parseCollections(json)
}
suspend fun getCurrentCollections(): List<Collection> {
val prefs = store().data.first()
return parseCollections(prefs[collectionsKey])
}
suspend fun exportCurrentProfileJson(): String? {
val prefs = store().data.first()
return prefs[collectionsKey]
}
fun validateCollectionsJson(json: String): ValidationResult {
if (json.isBlank()) return ValidationResult(false, "Empty input")
return try {
val type = object : TypeToken<List<Map<String, Any?>>>() {}.type
val parsed = gson.fromJson<List<Map<String, Any?>>>(json, type)
?: return ValidationResult(false, "Invalid format: expected an array")
if (parsed.isEmpty()) return ValidationResult(false, "Empty array: no collections found")
var folderCount = 0
val validShapes = setOf("POSTER", "LANDSCAPE", "SQUARE", "poster", "wide", "square")
for ((i, item) in parsed.withIndex()) {
val id = item["id"] as? String
if (id.isNullOrBlank()) return ValidationResult(false, "Collection ${i + 1}: missing or invalid \"id\"")
val title = item["title"] as? String
?: return ValidationResult(false, "Collection \"$id\": missing or invalid \"title\"")
val folders = item["folders"] as? List<*>
?: return ValidationResult(false, "Collection \"$title\": \"folders\" must be an array")
for ((j, f) in folders.withIndex()) {
val folder = f as? Map<*, *>
?: return ValidationResult(false, "Collection \"$title\", folder ${j + 1}: invalid format")
val folderId = folder["id"] as? String
if (folderId.isNullOrBlank()) return ValidationResult(false, "Collection \"$title\", folder ${j + 1}: missing \"id\"")
val folderTitle = folder["title"] as? String
?: return ValidationResult(false, "Collection \"$title\", folder \"$folderId\": missing \"title\"")
val sources = folder["catalogSources"] as? List<*>
?: return ValidationResult(false, "Collection \"$title\", folder \"$folderTitle\": \"catalogSources\" must be an array")
val shape = folder["tileShape"] as? String
if (shape != null && shape !in validShapes) {
return ValidationResult(false, "Collection \"$title\", folder \"$folderTitle\": invalid tileShape \"$shape\"")
}
for ((k, s) in sources.withIndex()) {
val source = s as? Map<*, *>
?: return ValidationResult(false, "Collection \"$title\", folder \"$folderTitle\", source ${k + 1}: invalid format")
if (source["addonId"] !is String || source["type"] !is String || source["catalogId"] !is String) {
return ValidationResult(false, "Collection \"$title\", folder \"$folderTitle\", source ${k + 1}: missing required fields")
}
}
folderCount++
}
}
ValidationResult(true, collectionCount = parsed.size, folderCount = folderCount)
} catch (e: Exception) {
ValidationResult(false, "JSON parse error: ${e.message}")
}
}
private fun parseCollections(json: String?): List<Collection> {
if (json.isNullOrBlank()) return emptyList()
return try {
val type = object : TypeToken<List<SerializableCollection>>() {}.type
val parsed = gson.fromJson<List<SerializableCollection>>(json, type).orEmpty()
parsed.map { it.toDomain() }
} catch (_: Exception) {
emptyList()
}
}
@androidx.annotation.Keep
private data class SerializableCollection(
val id: String,
val title: String,
val backdropImageUrl: String? = null,
val pinToTop: Boolean = false,
val focusGlowEnabled: Boolean? = null,
val viewMode: String = "TABBED_GRID",
val showAllTab: Boolean = true,
val folders: List<SerializableFolder> = emptyList()
)
@androidx.annotation.Keep
private data class SerializableFolder(
val id: String,
val title: String,
val coverImageUrl: String? = null,
val focusGifUrl: String? = null,
val focusGifEnabled: Boolean? = null,
val coverEmoji: String? = null,
val tileShape: String = "SQUARE",
val hideTitle: Boolean = false,
val catalogSources: List<SerializableCatalogSource> = emptyList()
)
@androidx.annotation.Keep
private data class SerializableCatalogSource(
val addonId: String,
val type: String,
val catalogId: String,
val genre: String? = null
)
private fun Collection.toSerializable() = SerializableCollection(
id = id,
title = title,
backdropImageUrl = backdropImageUrl,
pinToTop = pinToTop,
focusGlowEnabled = focusGlowEnabled,
viewMode = viewMode.name,
showAllTab = showAllTab,
folders = folders.map { folder ->
SerializableFolder(
id = folder.id,
title = folder.title,
coverImageUrl = folder.coverImageUrl,
focusGifUrl = folder.focusGifUrl,
focusGifEnabled = folder.focusGifEnabled,
coverEmoji = folder.coverEmoji,
tileShape = folder.tileShape.name,
hideTitle = folder.hideTitle,
catalogSources = folder.catalogSources.map { source ->
SerializableCatalogSource(
addonId = source.addonId,
type = source.type,
catalogId = source.catalogId,
genre = source.genre
)
}
)
}
)
private fun SerializableCollection.toDomain() = Collection(
id = id,
title = title,
backdropImageUrl = backdropImageUrl,
pinToTop = pinToTop,
focusGlowEnabled = focusGlowEnabled ?: true,
viewMode = FolderViewMode.fromString(viewMode),
showAllTab = showAllTab,
folders = folders.map { folder ->
CollectionFolder(
id = folder.id,
title = folder.title,
coverImageUrl = folder.coverImageUrl,
focusGifUrl = folder.focusGifUrl,
focusGifEnabled = folder.focusGifEnabled ?: true,
coverEmoji = folder.coverEmoji,
tileShape = PosterShape.fromString(folder.tileShape),
hideTitle = folder.hideTitle,
catalogSources = folder.catalogSources.map { source ->
CollectionCatalogSource(
addonId = source.addonId,
type = source.type,
catalogId = source.catalogId,
genre = source.genre
)
}
)
}
)
}

View file

@ -1,148 +0,0 @@
package com.nuvio.tv.data.local
import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.core.profile.ProfileManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class CachedNextUpItem(
val contentId: String,
val contentType: String,
val name: String,
val poster: String?,
val backdrop: String?,
val logo: String?,
val videoId: String,
val season: Int,
val episode: Int,
val episodeTitle: String?,
val episodeDescription: String? = null,
val thumbnail: String?,
val released: String? = null,
val hasAired: Boolean = true,
val airDateLabel: String? = null,
val lastWatched: Long,
val imdbRating: Float? = null,
val genres: List<String> = emptyList(),
val releaseInfo: String? = null,
val sortTimestamp: Long,
val releaseTimestamp: Long? = null,
val isReleaseAlert: Boolean = false,
val isNewSeasonRelease: Boolean = false,
val seedSeason: Int? = null,
val seedEpisode: Int? = null,
val contentLanguage: String? = null
)
data class CachedInProgressItem(
val contentId: String,
val contentType: String,
val name: String,
val poster: String?,
val backdrop: String?,
val logo: String?,
val videoId: String,
val season: Int?,
val episode: Int?,
val episodeTitle: String?,
val position: Long,
val duration: Long,
val lastWatched: Long,
val progressPercent: Float?,
val episodeThumbnail: String? = null,
val episodeDescription: String? = null,
val episodeImdbRating: Float? = null,
val genres: List<String> = emptyList(),
val releaseInfo: String? = null,
val contentLanguage: String? = null
)
@Singleton
class ContinueWatchingEnrichmentCache @Inject constructor(
@ApplicationContext private val context: Context,
private val profileManager: ProfileManager
) {
companion object {
private const val TAG = "CwEnrichCache"
}
private val gson = Gson()
private val mutex = Mutex()
// --- Next Up snapshot cache ---
private fun nextUpFile(): File {
val profileId = profileManager.activeProfileId.value
val dir = File(context.cacheDir, "cw_enrichment")
dir.mkdirs()
return File(dir, "nextup_${profileId}.json")
}
suspend fun getNextUpSnapshot(): List<CachedNextUpItem> = withContext(Dispatchers.IO) {
mutex.withLock {
try {
val file = nextUpFile()
if (!file.exists()) return@withContext emptyList()
gson.fromJson(file.readText(), object : TypeToken<List<CachedNextUpItem>>() {}.type)
?: emptyList()
} catch (e: Exception) {
Log.w(TAG, "Failed to read next-up cache: ${e.message}")
emptyList()
}
}
}
suspend fun saveNextUpSnapshot(items: List<CachedNextUpItem>) = withContext(Dispatchers.IO) {
mutex.withLock {
try {
val file = nextUpFile()
file.writeText(gson.toJson(items))
} catch (e: Exception) {
Log.w(TAG, "Failed to write next-up cache: ${e.message}")
}
}
}
// --- In-progress snapshot cache ---
private fun inProgressFile(): File {
val profileId = profileManager.activeProfileId.value
val dir = File(context.cacheDir, "cw_enrichment")
dir.mkdirs()
return File(dir, "inprogress_${profileId}.json")
}
suspend fun getInProgressSnapshot(): List<CachedInProgressItem> = withContext(Dispatchers.IO) {
mutex.withLock {
try {
val file = inProgressFile()
if (!file.exists()) return@withContext emptyList()
gson.fromJson(file.readText(), object : TypeToken<List<CachedInProgressItem>>() {}.type)
?: emptyList()
} catch (e: Exception) {
Log.w(TAG, "Failed to read in-progress cache: ${e.message}")
emptyList()
}
}
}
suspend fun saveInProgressSnapshot(items: List<CachedInProgressItem>) = withContext(Dispatchers.IO) {
mutex.withLock {
try {
val file = inProgressFile()
file.writeText(gson.toJson(items))
} catch (e: Exception) {
Log.w(TAG, "Failed to write in-progress cache: ${e.message}")
}
}
}
}

View file

@ -3,22 +3,13 @@ package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.domain.model.FocusedPosterTrailerPlaybackTarget
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.core.sync.LocalHomeCatalogSettingsState
import com.nuvio.tv.core.sync.SyncHomeCatalogPayload
import com.nuvio.tv.core.sync.buildHomeCatalogSyncPayload
import com.nuvio.tv.core.sync.homeCatalogKey
import com.nuvio.tv.core.sync.homeCollectionKey
import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.Collection
import com.nuvio.tv.domain.model.FocusedPosterTrailerPlaybackTarget
import com.nuvio.tv.domain.model.HomeLayout
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@ -35,7 +26,7 @@ class LayoutPreferenceDataStore @Inject constructor(
private const val DEFAULT_POSTER_CARD_HEIGHT_DP = 189
private const val DEFAULT_POSTER_CARD_CORNER_RADIUS_DP = 12
private const val DEFAULT_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 3
private const val MIN_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 0
private const val MIN_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 1
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
@ -49,7 +40,6 @@ class LayoutPreferenceDataStore @Inject constructor(
private val heroCatalogKeysKey = stringPreferencesKey("hero_catalog_keys")
private val homeCatalogOrderKeysKey = stringPreferencesKey("home_catalog_order_keys")
private val disabledHomeCatalogKeysKey = stringPreferencesKey("disabled_home_catalog_keys")
private val customCatalogTitlesKey = stringPreferencesKey("custom_catalog_titles")
private val sidebarCollapsedKey = booleanPreferencesKey("sidebar_collapsed_by_default")
private val modernSidebarEnabledKey = booleanPreferencesKey("modern_sidebar_enabled")
private val legacyModernSidebarEnabledKey = booleanPreferencesKey("glass_sidepanel_enabled")
@ -70,13 +60,8 @@ class LayoutPreferenceDataStore @Inject constructor(
private val posterCardHeightDpKey = intPreferencesKey("poster_card_height_dp")
private val posterCardCornerRadiusDpKey = intPreferencesKey("poster_card_corner_radius_dp")
private val blurUnwatchedEpisodesKey = booleanPreferencesKey("blur_unwatched_episodes")
private val blurContinueWatchingNextUpKey = booleanPreferencesKey("blur_continue_watching_next_up")
private val detailPageTrailerButtonEnabledKey = booleanPreferencesKey("detail_page_trailer_button_enabled")
private val preferExternalMetaAddonDetailKey = booleanPreferencesKey("prefer_external_meta_addon_detail")
private val modernHeroFullScreenBackdropKey = booleanPreferencesKey("modern_hero_full_screen_backdrop")
private val hideUnreleasedContentKey = booleanPreferencesKey("hide_unreleased_content")
private val showFullReleaseDateKey = booleanPreferencesKey("show_full_release_date")
private val memoryOnlyVerticalScrollKey = booleanPreferencesKey("memory_only_vertical_scroll")
private fun <T> profileFlow(extract: (prefs: androidx.datastore.preferences.core.Preferences) -> T): Flow<T> =
profileManager.activeProfileId.flatMapLatest { pid ->
@ -121,10 +106,6 @@ class LayoutPreferenceDataStore @Inject constructor(
parseCatalogKeys(prefs[disabledHomeCatalogKeysKey])
}
val customCatalogTitles: Flow<Map<String, String>> = profileFlow { prefs ->
parseCustomTitles(prefs[customCatalogTitlesKey])
}
val sidebarCollapsedByDefault: Flow<Boolean> = profileFlow { prefs ->
val modernSidebarEnabled =
prefs[modernSidebarEnabledKey] ?: prefs[legacyModernSidebarEnabledKey] ?: false
@ -147,10 +128,6 @@ class LayoutPreferenceDataStore @Inject constructor(
prefs[modernLandscapePostersEnabledKey] ?: false
}
val modernHeroFullScreenBackdropEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[modernHeroFullScreenBackdropKey] ?: false
}
val heroSectionEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[heroSectionEnabledKey] ?: true
}
@ -213,34 +190,12 @@ class LayoutPreferenceDataStore @Inject constructor(
prefs[blurUnwatchedEpisodesKey] ?: false
}
val blurContinueWatchingNextUp: Flow<Boolean> = profileFlow { prefs ->
prefs[blurContinueWatchingNextUpKey] ?: false
}
val detailPageTrailerButtonEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[detailPageTrailerButtonEnabledKey] ?: false
}
val preferExternalMetaAddonDetail: Flow<Boolean> = profileFlow { prefs ->
prefs[preferExternalMetaAddonDetailKey] ?: true
}
val hideUnreleasedContent: Flow<Boolean> = profileFlow { prefs ->
prefs[hideUnreleasedContentKey] ?: false
}
val showFullReleaseDate: Flow<Boolean> = profileFlow { prefs ->
prefs[showFullReleaseDateKey] ?: true
}
val memoryOnlyVerticalScroll: Flow<Boolean> = profileFlow { prefs ->
prefs[memoryOnlyVerticalScrollKey] ?: false
}
suspend fun setMemoryOnlyVerticalScroll(enabled: Boolean) {
store().edit { prefs ->
prefs[memoryOnlyVerticalScrollKey] = enabled
}
prefs[preferExternalMetaAddonDetailKey] ?: false
}
suspend fun setLayout(layout: HomeLayout) {
@ -328,12 +283,6 @@ class LayoutPreferenceDataStore @Inject constructor(
}
}
suspend fun setModernHeroFullScreenBackdropEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[modernHeroFullScreenBackdropKey] = enabled
}
}
suspend fun setHeroSectionEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[heroSectionEnabledKey] = enabled
@ -428,12 +377,6 @@ class LayoutPreferenceDataStore @Inject constructor(
}
}
suspend fun setBlurContinueWatchingNextUp(enabled: Boolean) {
store().edit { prefs ->
prefs[blurContinueWatchingNextUpKey] = enabled
}
}
suspend fun setDetailPageTrailerButtonEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[detailPageTrailerButtonEnabledKey] = enabled
@ -446,18 +389,6 @@ class LayoutPreferenceDataStore @Inject constructor(
}
}
suspend fun setHideUnreleasedContent(enabled: Boolean) {
store().edit { prefs ->
prefs[hideUnreleasedContentKey] = enabled
}
}
suspend fun setShowFullReleaseDate(enabled: Boolean) {
store().edit { prefs ->
prefs[showFullReleaseDateKey] = enabled
}
}
private fun parseCatalogKeys(json: String?): List<String> {
if (json.isNullOrBlank()) return emptyList()
return try {
@ -476,83 +407,4 @@ class LayoutPreferenceDataStore @Inject constructor(
.distinct()
.toList()
}
private fun parseCustomTitles(json: String?): Map<String, String> {
if (json.isNullOrBlank()) return emptyMap()
return try {
val type = object : TypeToken<Map<String, String>>() {}.type
gson.fromJson<Map<String, String>>(json, type).orEmpty()
} catch (_: Exception) {
emptyMap()
}
}
suspend fun setCustomCatalogTitles(titles: Map<String, String>) {
store().edit { prefs ->
val filtered = titles.filterValues { it.isNotBlank() }
if (filtered.isEmpty()) {
prefs.remove(customCatalogTitlesKey)
} else {
prefs[customCatalogTitlesKey] = gson.toJson(filtered)
}
}
}
internal suspend fun getHomeCatalogSettingsState(): LocalHomeCatalogSettingsState {
return readHomeCatalogSettingsState(store().data.first())
}
internal suspend fun exportCatalogSettingsToSyncPayload(
addons: List<Addon>,
collections: List<Collection>
): SyncHomeCatalogPayload {
return buildHomeCatalogSyncPayload(
addons = addons,
collections = collections,
localState = getHomeCatalogSettingsState()
)
}
suspend fun applyCatalogSettingsFromRemote(payload: SyncHomeCatalogPayload) {
val sortedItems = payload.items.sortedBy { it.order }
val orderKeys = sortedItems.map { item ->
if (item.isCollection) homeCollectionKey(item.collectionId)
else homeCatalogKey(item.addonId, item.type, item.catalogId)
}
val disabledKeys = sortedItems.filter { !it.enabled }.map { item ->
if (item.isCollection) homeCollectionKey(item.collectionId)
else homeCatalogKey(item.addonId, item.type, item.catalogId)
}
val titles = sortedItems.associate { item ->
val key = if (item.isCollection) homeCollectionKey(item.collectionId)
else homeCatalogKey(item.addonId, item.type, item.catalogId)
key to item.customTitle
}.filterValues { it.isNotBlank() }
store().edit { prefs ->
if (orderKeys.isNotEmpty()) {
prefs[homeCatalogOrderKeysKey] = gson.toJson(orderKeys)
} else {
prefs.remove(homeCatalogOrderKeysKey)
}
if (disabledKeys.isNotEmpty()) {
prefs[disabledHomeCatalogKeysKey] = gson.toJson(disabledKeys)
} else {
prefs.remove(disabledHomeCatalogKeysKey)
}
if (titles.isNotEmpty()) {
prefs[customCatalogTitlesKey] = gson.toJson(titles)
} else {
prefs.remove(customCatalogTitlesKey)
}
}
}
private fun readHomeCatalogSettingsState(prefs: Preferences): LocalHomeCatalogSettingsState {
return LocalHomeCatalogSettingsState(
orderKeys = parseCatalogKeys(prefs[homeCatalogOrderKeysKey]),
disabledKeys = parseCatalogKeys(prefs[disabledHomeCatalogKeysKey]).toSet(),
customTitles = parseCustomTitles(prefs[customCatalogTitlesKey])
)
}
}

View file

@ -2,7 +2,6 @@ package com.nuvio.tv.data.local
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.google.gson.Gson
@ -29,19 +28,6 @@ class LibraryPreferences @Inject constructor(
private val gson = Gson()
private val libraryItemsKey = stringSetPreferencesKey("library_items")
private val sortOptionKey = stringPreferencesKey("library_sort_option")
val sortOption: Flow<String?> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences ->
preferences[sortOptionKey]
}
}
suspend fun setSortOption(key: String) {
store().edit { preferences ->
preferences[sortOptionKey] = key
}
}
val libraryItems: Flow<List<SavedLibraryItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences ->
@ -68,8 +54,7 @@ class LibraryPreferences @Inject constructor(
saved.id == item.id && saved.type.equals(item.type, ignoreCase = true)
} ?: false
}
val itemWithTimestamp = if (item.addedAt == 0L) item.copy(addedAt = System.currentTimeMillis()) else item
preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(itemWithTimestamp)
preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(item)
}
}

View file

@ -19,7 +19,6 @@ import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import javax.inject.Inject
import javax.inject.Singleton
import com.nuvio.tv.ui.util.languageCodeToName
/**
* Available subtitle languages
@ -29,90 +28,52 @@ data class SubtitleLanguage(
val name: String
)
val SubtitleLanguage.displayName: String
get() = languageCodeToName(code)
const val SUBTITLE_LANGUAGE_FORCED = "forced"
val AVAILABLE_SUBTITLE_LANGUAGES = listOf(
SubtitleLanguage("af", "Afrikaans"),
SubtitleLanguage("sq", "Albanian"),
SubtitleLanguage("am", "Amharic"),
SubtitleLanguage("ar", "Arabic"),
SubtitleLanguage("hy", "Armenian"),
SubtitleLanguage("az", "Azerbaijani"),
SubtitleLanguage("eu", "Basque"),
SubtitleLanguage("be", "Belarusian"),
SubtitleLanguage("bn", "Bengali"),
SubtitleLanguage("bs", "Bosnian"),
SubtitleLanguage("bg", "Bulgarian"),
SubtitleLanguage("my", "Burmese"),
SubtitleLanguage("ca", "Catalan"),
SubtitleLanguage("zh", "Chinese"),
SubtitleLanguage("zh-CN", "Chinese (Simplified)"),
SubtitleLanguage("zh-TW", "Chinese (Traditional)"),
SubtitleLanguage("hr", "Croatian"),
SubtitleLanguage("cs", "Czech"),
SubtitleLanguage("da", "Danish"),
SubtitleLanguage("nl", "Dutch"),
SubtitleLanguage("en", "English"),
SubtitleLanguage("et", "Estonian"),
SubtitleLanguage("tl", "Filipino"),
SubtitleLanguage("fi", "Finnish"),
SubtitleLanguage("es", "Spanish"),
SubtitleLanguage("fr", "French"),
SubtitleLanguage("gl", "Galician"),
SubtitleLanguage("ka", "Georgian"),
SubtitleLanguage("de", "German"),
SubtitleLanguage("el", "Greek"),
SubtitleLanguage("gu", "Gujarati"),
SubtitleLanguage("he", "Hebrew"),
SubtitleLanguage("hi", "Hindi"),
SubtitleLanguage("hu", "Hungarian"),
SubtitleLanguage("is", "Icelandic"),
SubtitleLanguage("id", "Indonesian"),
SubtitleLanguage("ga", "Irish"),
SubtitleLanguage("it", "Italian"),
SubtitleLanguage("ja", "Japanese"),
SubtitleLanguage("kn", "Kannada"),
SubtitleLanguage("kk", "Kazakh"),
SubtitleLanguage("km", "Khmer"),
SubtitleLanguage("ko", "Korean"),
SubtitleLanguage("lo", "Lao"),
SubtitleLanguage("lv", "Latvian"),
SubtitleLanguage("lt", "Lithuanian"),
SubtitleLanguage("mk", "Macedonian"),
SubtitleLanguage("ms", "Malay"),
SubtitleLanguage("ml", "Malayalam"),
SubtitleLanguage("mt", "Maltese"),
SubtitleLanguage("mr", "Marathi"),
SubtitleLanguage("mn", "Mongolian"),
SubtitleLanguage("ne", "Nepali"),
SubtitleLanguage("no", "Norwegian"),
SubtitleLanguage("pa", "Punjabi"),
SubtitleLanguage("fa", "Persian"),
SubtitleLanguage("pl", "Polish"),
SubtitleLanguage("pt", "Portuguese (Portugal)"),
SubtitleLanguage("pt-br", "Portuguese (Brazil)"),
SubtitleLanguage("ro", "Romanian"),
SubtitleLanguage("ru", "Russian"),
SubtitleLanguage("sr", "Serbian"),
SubtitleLanguage("si", "Sinhala"),
SubtitleLanguage("ja", "Japanese"),
SubtitleLanguage("ko", "Korean"),
SubtitleLanguage("zh", "Chinese"),
SubtitleLanguage("ar", "Arabic"),
SubtitleLanguage("hi", "Hindi"),
SubtitleLanguage("tr", "Turkish"),
SubtitleLanguage("pl", "Polish"),
SubtitleLanguage("nl", "Dutch"),
SubtitleLanguage("sv", "Swedish"),
SubtitleLanguage("da", "Danish"),
SubtitleLanguage("no", "Norwegian"),
SubtitleLanguage("fi", "Finnish"),
SubtitleLanguage("th", "Thai"),
SubtitleLanguage("vi", "Vietnamese"),
SubtitleLanguage("id", "Indonesian"),
SubtitleLanguage("ms", "Malay"),
SubtitleLanguage("he", "Hebrew"),
SubtitleLanguage("el", "Greek"),
SubtitleLanguage("cs", "Czech"),
SubtitleLanguage("hu", "Hungarian"),
SubtitleLanguage("ro", "Romanian"),
SubtitleLanguage("uk", "Ukrainian"),
SubtitleLanguage("bg", "Bulgarian"),
SubtitleLanguage("hr", "Croatian"),
SubtitleLanguage("sk", "Slovak"),
SubtitleLanguage("sl", "Slovenian"),
SubtitleLanguage("es", "Spanish"),
SubtitleLanguage("es-419", "Spanish (Latin America)"),
SubtitleLanguage("sw", "Swahili"),
SubtitleLanguage("sv", "Swedish"),
SubtitleLanguage("sr", "Serbian"),
SubtitleLanguage("ta", "Tamil"),
SubtitleLanguage("te", "Telugu"),
SubtitleLanguage("th", "Thai"),
SubtitleLanguage("tr", "Turkish"),
SubtitleLanguage("uk", "Ukrainian"),
SubtitleLanguage("ur", "Urdu"),
SubtitleLanguage("uz", "Uzbek"),
SubtitleLanguage("vi", "Vietnamese"),
SubtitleLanguage("cy", "Welsh"),
SubtitleLanguage("zu", "Zulu")
SubtitleLanguage("ml", "Malayalam"),
SubtitleLanguage("bn", "Bengali"),
SubtitleLanguage("mr", "Marathi"),
SubtitleLanguage("gu", "Gujarati"),
SubtitleLanguage("kn", "Kannada"),
SubtitleLanguage("pa", "Punjabi")
)
/**
@ -150,7 +111,6 @@ data class BufferSettings(
object AudioLanguageOption {
const val DEFAULT = "default" // Use media file default
const val DEVICE = "device" // Use device locale
const val ORIGINAL = "original" // Use content's original language (from TMDB)
}
/**
@ -158,8 +118,6 @@ object AudioLanguageOption {
*/
data class PlayerSettings(
val playerPreference: PlayerPreference = PlayerPreference.INTERNAL,
val internalPlayerEngine: InternalPlayerEngine = InternalPlayerEngine.EXOPLAYER,
val autoSwitchInternalPlayerOnError: Boolean = false,
val useLibass: Boolean = false,
val libassRenderType: LibassRenderType = LibassRenderType.OVERLAY_OPEN_GL,
val subtitleStyle: SubtitleStyleSettings = SubtitleStyleSettings(),
@ -168,21 +126,15 @@ data class PlayerSettings(
val decoderPriority: Int = 1, // EXTENSION_RENDERER_MODE_ON (0=off, 1=on, 2=prefer)
val tunnelingEnabled: Boolean = false,
val skipSilence: Boolean = false,
val audioAmplificationDb: Int = 0,
val persistAudioAmplification: Boolean = false,
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
val secondaryPreferredAudioLanguage: String? = null,
val loadingOverlayEnabled: Boolean = true,
val showPlayerLoadingStatus: Boolean = true,
val pauseOverlayEnabled: Boolean = true,
val osdClockEnabled: Boolean = true,
val skipIntroEnabled: Boolean = true,
// Dolby Vision Profile 7 → HEVC fallback (requires forked ExoPlayer)
val mapDV7ToHevc: Boolean = false,
val mpvHardwareDecodeMode: MpvHardwareDecodeMode = MpvHardwareDecodeMode.AUTO_SAFE,
// Display settings
val frameRateMatchingMode: FrameRateMatchingMode = FrameRateMatchingMode.OFF,
val resolutionMatchingEnabled: Boolean = false,
// Stream selection settings
val streamAutoPlayMode: StreamAutoPlayMode = StreamAutoPlayMode.MANUAL,
val streamAutoPlaySource: StreamAutoPlaySource = StreamAutoPlaySource.ALL_SOURCES,
@ -190,16 +142,13 @@ data class PlayerSettings(
val streamAutoPlaySelectedPlugins: Set<String> = emptySet(),
val streamAutoPlayRegex: String = "",
val streamAutoPlayNextEpisodeEnabled: Boolean = false,
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = true,
val streamAutoPlayTimeoutSeconds: Int = 3,
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = false,
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
val nextEpisodeThresholdPercent: Float = 99f,
val nextEpisodeThresholdPercent: Float = 98f,
val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f,
val streamReuseLastLinkEnabled: Boolean = false,
val streamReuseLastLinkCacheHours: Int = 24,
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE,
val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES,
val resizeMode: Int = 0
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE
)
enum class StreamAutoPlayMode {
@ -231,31 +180,12 @@ enum class SubtitleOrganizationMode {
BY_ADDON
}
enum class AddonSubtitleStartupMode {
FAST_STARTUP,
PREFERRED_ONLY,
ALL_SUBTITLES
}
enum class MpvHardwareDecodeMode {
LEGACY_DIRECT_COPY,
AUTO_SAFE,
HARDWARE_COPY,
HARDWARE_DIRECT,
DISABLED
}
enum class PlayerPreference {
INTERNAL,
EXTERNAL,
ASK_EVERY_TIME
}
enum class InternalPlayerEngine {
EXOPLAYER,
MVP_PLAYER
}
/**
* Enum representing the different libass render types
* Maps to io.github.peerless2012.ass.media.type.AssRenderType
@ -275,8 +205,6 @@ class PlayerSettingsDataStore @Inject constructor(
) {
companion object {
private const val FEATURE = "player_settings"
private const val AUDIO_AMPLIFICATION_DB_MIN = 0
private const val AUDIO_AMPLIFICATION_DB_MAX = 10
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
@ -286,9 +214,6 @@ class PlayerSettingsDataStore @Inject constructor(
// Player preference key
private val playerPreferenceKey = stringPreferencesKey("player_preference")
private val internalPlayerEngineKey = stringPreferencesKey("internal_player_engine")
private val autoSwitchInternalPlayerOnErrorKey =
booleanPreferencesKey("auto_switch_internal_player_on_error")
// Libass settings keys
private val useLibassKey = booleanPreferencesKey("use_libass")
@ -298,20 +223,14 @@ class PlayerSettingsDataStore @Inject constructor(
private val decoderPriorityKey = intPreferencesKey("decoder_priority")
private val tunnelingEnabledKey = booleanPreferencesKey("tunneling_enabled")
private val skipSilenceKey = booleanPreferencesKey("skip_silence")
private val audioAmplificationDbKey = intPreferencesKey("audio_amplification_db")
private val persistAudioAmplificationKey = booleanPreferencesKey("persist_audio_amplification")
private val preferredAudioLanguageKey = stringPreferencesKey("preferred_audio_language")
private val secondaryPreferredAudioLanguageKey = stringPreferencesKey("secondary_preferred_audio_language")
private val loadingOverlayEnabledKey = booleanPreferencesKey("loading_overlay_enabled")
private val showPlayerLoadingStatusKey = booleanPreferencesKey("show_player_loading_status")
private val pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled")
private val osdClockEnabledKey = booleanPreferencesKey("osd_clock_enabled")
private val skipIntroEnabledKey = booleanPreferencesKey("skip_intro_enabled")
private val mapDV7ToHevcKey = booleanPreferencesKey("map_dv7_to_hevc")
private val mpvHardwareDecodeModeKey = stringPreferencesKey("mpv_hardware_decode_mode")
private val frameRateMatchingKey = booleanPreferencesKey("frame_rate_matching")
private val frameRateMatchingModeKey = stringPreferencesKey("frame_rate_matching_mode")
private val resolutionMatchingEnabledKey = booleanPreferencesKey("resolution_matching_enabled")
private val streamAutoPlayModeKey = stringPreferencesKey("stream_auto_play_mode")
private val streamAutoPlaySourceKey = stringPreferencesKey("stream_auto_play_source")
private val streamAutoPlaySelectedAddonsKey = stringSetPreferencesKey("stream_auto_play_selected_addons")
@ -319,7 +238,6 @@ class PlayerSettingsDataStore @Inject constructor(
private val streamAutoPlayRegexKey = stringPreferencesKey("stream_auto_play_regex")
private val streamAutoPlayNextEpisodeEnabledKey = booleanPreferencesKey("stream_auto_play_next_episode_enabled")
private val streamAutoPlayPreferBingeGroupForNextEpisodeKey = booleanPreferencesKey("stream_auto_play_prefer_bingegroup_next_episode")
private val streamAutoPlayTimeoutSecondsKey = intPreferencesKey("stream_auto_play_timeout_seconds")
private val nextEpisodeThresholdModeKey = stringPreferencesKey("next_episode_threshold_mode")
private val nextEpisodeThresholdPercentLegacyKey = intPreferencesKey("next_episode_threshold_percent")
private val nextEpisodeThresholdMinutesBeforeEndLegacyKey = intPreferencesKey("next_episode_threshold_minutes_before_end")
@ -328,8 +246,6 @@ class PlayerSettingsDataStore @Inject constructor(
private val streamReuseLastLinkEnabledKey = booleanPreferencesKey("stream_reuse_last_link_enabled")
private val streamReuseLastLinkCacheHoursKey = intPreferencesKey("stream_reuse_last_link_cache_hours")
private val subtitleOrganizationModeKey = stringPreferencesKey("subtitle_organization_mode")
private val addonSubtitleStartupModeKey = stringPreferencesKey("addon_subtitle_startup_mode")
private val resizeModeKey = intPreferencesKey("resize_mode")
// Subtitle style settings keys
private val subtitlePreferredLanguageKey = stringPreferencesKey("subtitle_preferred_language")
@ -388,19 +304,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
val secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
if (secondaryPreferredAudioLanguage != null) {
val normalizedSecondaryPreferredAudioLanguage =
normalizeSecondaryAudioLanguageCode(secondaryPreferredAudioLanguage)
if (normalizedSecondaryPreferredAudioLanguage != secondaryPreferredAudioLanguage) {
if (normalizedSecondaryPreferredAudioLanguage != null) {
prefs[secondaryPreferredAudioLanguageKey] = normalizedSecondaryPreferredAudioLanguage
} else {
prefs.remove(secondaryPreferredAudioLanguageKey)
}
}
}
val preferredSubtitleLanguage = prefs[subtitlePreferredLanguageKey]
if (preferredSubtitleLanguage != null) {
val normalizedPreferredSubtitleLanguage =
@ -431,10 +334,6 @@ class PlayerSettingsDataStore @Inject constructor(
playerPreference = prefs[playerPreferenceKey]?.let {
runCatching { PlayerPreference.valueOf(it) }.getOrDefault(PlayerPreference.INTERNAL)
} ?: PlayerPreference.INTERNAL,
internalPlayerEngine = prefs[internalPlayerEngineKey]?.let {
runCatching { InternalPlayerEngine.valueOf(it) }.getOrDefault(InternalPlayerEngine.EXOPLAYER)
} ?: InternalPlayerEngine.EXOPLAYER,
autoSwitchInternalPlayerOnError = prefs[autoSwitchInternalPlayerOnErrorKey] ?: false,
useLibass = prefs[useLibassKey] ?: false,
libassRenderType = prefs[libassRenderTypeKey]?.let {
try { LibassRenderType.valueOf(it) } catch (e: Exception) { LibassRenderType.OVERLAY_OPEN_GL }
@ -442,23 +341,14 @@ class PlayerSettingsDataStore @Inject constructor(
decoderPriority = prefs[decoderPriorityKey] ?: 1,
tunnelingEnabled = prefs[tunnelingEnabledKey] ?: false,
skipSilence = prefs[skipSilenceKey] ?: false,
audioAmplificationDb = (prefs[audioAmplificationDbKey] ?: 0).coerceIn(
AUDIO_AMPLIFICATION_DB_MIN,
AUDIO_AMPLIFICATION_DB_MAX
),
persistAudioAmplification = prefs[persistAudioAmplificationKey] ?: false,
preferredAudioLanguage = normalizeSelectableLanguageCode(
prefs[preferredAudioLanguageKey] ?: AudioLanguageOption.DEVICE
),
secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
?.let(::normalizeSecondaryAudioLanguageCode),
loadingOverlayEnabled = prefs[loadingOverlayEnabledKey] ?: true,
showPlayerLoadingStatus = prefs[showPlayerLoadingStatusKey] ?: true,
pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true,
osdClockEnabled = prefs[osdClockEnabledKey] ?: true,
skipIntroEnabled = prefs[skipIntroEnabledKey] ?: true,
mapDV7ToHevc = prefs[mapDV7ToHevcKey] ?: false,
mpvHardwareDecodeMode = parseMpvHardwareDecodeMode(prefs[mpvHardwareDecodeModeKey]),
frameRateMatchingMode = prefs[frameRateMatchingModeKey]?.let {
runCatching { FrameRateMatchingMode.valueOf(it) }.getOrNull()
} ?: if (prefs[frameRateMatchingKey] == true) {
@ -466,7 +356,6 @@ class PlayerSettingsDataStore @Inject constructor(
} else {
FrameRateMatchingMode.OFF
},
resolutionMatchingEnabled = prefs[resolutionMatchingEnabledKey] ?: false,
streamAutoPlayMode = prefs[streamAutoPlayModeKey]?.let {
runCatching { StreamAutoPlayMode.valueOf(it) }.getOrDefault(StreamAutoPlayMode.MANUAL)
} ?: StreamAutoPlayMode.MANUAL,
@ -478,15 +367,14 @@ class PlayerSettingsDataStore @Inject constructor(
streamAutoPlayRegex = prefs[streamAutoPlayRegexKey] ?: "",
streamAutoPlayNextEpisodeEnabled = prefs[streamAutoPlayNextEpisodeEnabledKey] ?: false,
streamAutoPlayPreferBingeGroupForNextEpisode =
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: true,
streamAutoPlayTimeoutSeconds = (prefs[streamAutoPlayTimeoutSecondsKey] ?: 3).coerceIn(0, 11),
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: false,
nextEpisodeThresholdMode = prefs[nextEpisodeThresholdModeKey]?.let {
runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrDefault(NextEpisodeThresholdMode.PERCENTAGE)
} ?: NextEpisodeThresholdMode.PERCENTAGE,
nextEpisodeThresholdPercent = normalizeHalfStep(
value = prefs[nextEpisodeThresholdPercentKey]
?: prefs[nextEpisodeThresholdPercentLegacyKey]?.toFloat()
?: 99f,
?: 98f,
min = 97f,
max = 99.5f
),
@ -500,8 +388,6 @@ class PlayerSettingsDataStore @Inject constructor(
streamReuseLastLinkEnabled = prefs[streamReuseLastLinkEnabledKey] ?: false,
streamReuseLastLinkCacheHours = (prefs[streamReuseLastLinkCacheHoursKey] ?: 24).coerceIn(1, 168),
subtitleOrganizationMode = parseSubtitleOrganizationMode(prefs[subtitleOrganizationModeKey]),
addonSubtitleStartupMode = parseAddonSubtitleStartupMode(prefs[addonSubtitleStartupModeKey]),
resizeMode = (prefs[resizeModeKey] ?: 0).coerceIn(0, 4),
subtitleStyle = SubtitleStyleSettings(
preferredLanguage = normalizeSelectableLanguageCode(
prefs[subtitlePreferredLanguageKey] ?: "en"
@ -558,18 +444,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setInternalPlayerEngine(engine: InternalPlayerEngine) {
store().edit { prefs ->
prefs[internalPlayerEngineKey] = engine.name
}
}
suspend fun setAutoSwitchInternalPlayerOnError(enabled: Boolean) {
store().edit { prefs ->
prefs[autoSwitchInternalPlayerOnErrorKey] = enabled
}
}
// Audio settings setters
suspend fun setDecoderPriority(priority: Int) {
@ -590,27 +464,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setAudioAmplificationDb(db: Int) {
store().edit { prefs ->
prefs[audioAmplificationDbKey] = db.coerceIn(
AUDIO_AMPLIFICATION_DB_MIN,
AUDIO_AMPLIFICATION_DB_MAX
)
}
}
suspend fun setPersistAudioAmplification(enabled: Boolean, dbToPersist: Int? = null) {
store().edit { prefs ->
prefs[persistAudioAmplificationKey] = enabled
if (enabled && dbToPersist != null) {
prefs[audioAmplificationDbKey] = dbToPersist.coerceIn(
AUDIO_AMPLIFICATION_DB_MIN,
AUDIO_AMPLIFICATION_DB_MAX
)
}
}
}
suspend fun setPreferredAudioLanguage(language: String) {
store().edit { prefs ->
prefs[preferredAudioLanguageKey] = normalizeSelectableLanguageCode(
@ -619,19 +472,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setSecondaryPreferredAudioLanguage(language: String?) {
store().edit { prefs ->
val normalizedLanguage = language
?.takeIf { it.isNotBlank() }
?.let(::normalizeSecondaryAudioLanguageCode)
if (normalizedLanguage != null) {
prefs[secondaryPreferredAudioLanguageKey] = normalizedLanguage
} else {
prefs.remove(secondaryPreferredAudioLanguageKey)
}
}
}
suspend fun setPauseOverlayEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[pauseOverlayEnabledKey] = enabled
@ -656,12 +496,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setShowPlayerLoadingStatus(enabled: Boolean) {
store().edit { prefs ->
prefs[showPlayerLoadingStatusKey] = enabled
}
}
suspend fun setFrameRateMatchingMode(mode: FrameRateMatchingMode) {
store().edit { prefs ->
prefs[frameRateMatchingModeKey] = mode.name
@ -669,12 +503,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setResolutionMatchingEnabled(enabled: Boolean) {
store().edit { prefs ->
prefs[resolutionMatchingEnabledKey] = enabled
}
}
suspend fun setFrameRateMatching(enabled: Boolean) {
setFrameRateMatchingMode(
if (enabled) FrameRateMatchingMode.START_STOP else FrameRateMatchingMode.OFF
@ -723,12 +551,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setStreamAutoPlayTimeoutSeconds(seconds: Int) {
store().edit { prefs ->
prefs[streamAutoPlayTimeoutSecondsKey] = seconds.coerceIn(0, 11)
}
}
suspend fun setNextEpisodeThresholdMode(mode: NextEpisodeThresholdMode) {
store().edit { prefs ->
prefs[nextEpisodeThresholdModeKey] = mode.name
@ -778,18 +600,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
suspend fun setAddonSubtitleStartupMode(mode: AddonSubtitleStartupMode) {
store().edit { prefs ->
prefs[addonSubtitleStartupModeKey] = mode.name
}
}
suspend fun setResizeMode(mode: Int) {
store().edit { prefs ->
prefs[resizeModeKey] = mode.coerceIn(0, 4)
}
}
private fun parseSubtitleOrganizationMode(value: String?): SubtitleOrganizationMode {
return when (value) {
null, "NONE" -> SubtitleOrganizationMode.NONE
@ -799,26 +609,6 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
private fun parseAddonSubtitleStartupMode(value: String?): AddonSubtitleStartupMode {
return when (value) {
null, "ALL_SUBTITLES" -> AddonSubtitleStartupMode.ALL_SUBTITLES
"PREFERRED_ONLY" -> AddonSubtitleStartupMode.PREFERRED_ONLY
"FAST_STARTUP" -> AddonSubtitleStartupMode.FAST_STARTUP
else -> AddonSubtitleStartupMode.ALL_SUBTITLES
}
}
private fun parseMpvHardwareDecodeMode(value: String?): MpvHardwareDecodeMode {
return when (value) {
null, "AUTO_SAFE" -> MpvHardwareDecodeMode.AUTO_SAFE
"HARDWARE_COPY" -> MpvHardwareDecodeMode.HARDWARE_COPY
"HARDWARE_DIRECT" -> MpvHardwareDecodeMode.HARDWARE_DIRECT
"DISABLED" -> MpvHardwareDecodeMode.DISABLED
"LEGACY_DIRECT_COPY" -> MpvHardwareDecodeMode.LEGACY_DIRECT_COPY
else -> MpvHardwareDecodeMode.AUTO_SAFE
}
}
private fun normalizeSelectableLanguageCode(language: String): String {
val code = language.trim().lowercase()
return when (code) {
@ -829,29 +619,12 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
private fun normalizeSecondaryAudioLanguageCode(language: String): String? {
val normalized = normalizeSelectableLanguageCode(language)
return when (normalized) {
AudioLanguageOption.DEFAULT,
AudioLanguageOption.DEVICE,
AudioLanguageOption.ORIGINAL,
SUBTITLE_LANGUAGE_FORCED -> null
else -> normalized
}
}
suspend fun setMapDV7ToHevc(enabled: Boolean) {
store().edit { prefs ->
prefs[mapDV7ToHevcKey] = enabled
}
}
suspend fun setMpvHardwareDecodeMode(mode: MpvHardwareDecodeMode) {
store().edit { prefs ->
prefs[mpvHardwareDecodeModeKey] = mode.name
}
}
/**
* Set whether to use libass for ASS/SSA subtitle rendering
*/

View file

@ -67,13 +67,9 @@ class PluginDataStore @Inject constructor(
get() {
val pid = effectiveProfileId()
val dirName = if (pid == 1) "plugin_code" else "plugin_code_p${pid}"
return File(context.filesDir, dirName)
return File(context.filesDir, dirName).also { it.mkdirs() }
}
private suspend fun ensureCodeDir(): File = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
codeDir.also { it.mkdirs() }
}
// Repositories
val repositories: Flow<List<PluginRepository>> = effectiveProfileIdFlow.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
@ -174,30 +170,21 @@ class PluginDataStore @Inject constructor(
return File(codeDir, "$scraperId.js")
}
suspend fun saveScraperCode(scraperId: String, code: String) {
val dir = ensureCodeDir()
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
File(dir, "$scraperId.js").writeText(code)
}
fun saveScraperCode(scraperId: String, code: String) {
getScraperCodeFile(scraperId).writeText(code)
}
suspend fun getScraperCode(scraperId: String): String? {
return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val file = File(codeDir, "$scraperId.js")
if (file.exists()) file.readText() else null
}
fun getScraperCode(scraperId: String): String? {
val file = getScraperCodeFile(scraperId)
return if (file.exists()) file.readText() else null
}
suspend fun deleteScraperCode(scraperId: String) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
File(codeDir, "$scraperId.js").delete()
}
fun deleteScraperCode(scraperId: String) {
getScraperCodeFile(scraperId).delete()
}
suspend fun clearAllScraperCode() {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
codeDir.listFiles()?.forEach { it.delete() }
}
fun clearAllScraperCode() {
codeDir.listFiles()?.forEach { it.delete() }
}
// Per-scraper settings

View file

@ -120,16 +120,14 @@ internal data class ProfileJson(
val name: String,
val avatarColorHex: String,
val usesPrimaryAddons: Boolean = false,
val usesPrimaryPlugins: Boolean = false,
val avatarId: String? = null
val usesPrimaryPlugins: Boolean = false
) {
fun toDomain() = UserProfile(
id = id,
name = name,
avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons,
usesPrimaryPlugins = usesPrimaryPlugins,
avatarId = avatarId
usesPrimaryPlugins = usesPrimaryPlugins
)
companion object {
@ -138,8 +136,7 @@ internal data class ProfileJson(
name = profile.name,
avatarColorHex = profile.avatarColorHex,
usesPrimaryAddons = profile.usesPrimaryAddons,
usesPrimaryPlugins = profile.usesPrimaryPlugins,
avatarId = profile.avatarId
usesPrimaryPlugins = profile.usesPrimaryPlugins
)
}
}

View file

@ -4,7 +4,6 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.ConcurrentHashMap
@ -16,17 +15,9 @@ class ProfileDataStoreFactory @Inject constructor(
@ApplicationContext private val context: Context
) {
private val cache = ConcurrentHashMap<String, DataStore<Preferences>>()
private val deletedProfileIds = ConcurrentHashMap.newKeySet<Int>()
fun get(profileId: Int, featureName: String): DataStore<Preferences> {
val fileName = if (profileId == 1) featureName else "${featureName}_p${profileId}"
if (profileId != 1 && profileId in deletedProfileIds) {
return cache.compute(fileName) { _, _ ->
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile(fileName)
}
}!!
}
return cache.getOrPut(fileName) {
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile(fileName)
@ -34,23 +25,10 @@ class ProfileDataStoreFactory @Inject constructor(
}
}
suspend fun clearProfile(profileId: Int) {
fun clearProfile(profileId: Int) {
if (profileId == 1) return
deletedProfileIds.add(profileId)
val suffix = "_p${profileId}"
val keysToRemove = cache.keys.filter { key -> key.endsWith(suffix) }
for (key in keysToRemove) {
val store = cache[key]
if (store != null) {
runCatching { store.edit { it.clear() } }
}
cache.remove(key)
}
}
fun isProfileDeleted(profileId: Int): Boolean = profileId in deletedProfileIds
fun markProfileCreated(profileId: Int) {
deletedProfileIds.remove(profileId)
keysToRemove.forEach { key -> cache.remove(key) }
}
}

View file

@ -1,77 +0,0 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SearchHistoryDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "search_history"
private const val DEFAULT_MAX_RECENT_SEARCHES = 8
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
factory.get(profileId, FEATURE)
private val gson = Gson()
private val recentSearchesKey = stringPreferencesKey("recent_searches")
val recentSearches: Flow<List<String>> = profileManager.activeProfileId.flatMapLatest { profileId ->
factory.get(profileId, FEATURE).data.map { prefs ->
parseRecentSearches(prefs[recentSearchesKey])
}
}
suspend fun saveRecentSearch(query: String, maxItems: Int = DEFAULT_MAX_RECENT_SEARCHES) {
val normalized = query.trim()
if (normalized.isEmpty()) return
val current = recentSearches.first()
val updated = buildList {
add(normalized)
addAll(current.filterNot { it.equals(normalized, ignoreCase = true) })
}.take(maxItems.coerceAtLeast(1))
store().edit { prefs ->
prefs[recentSearchesKey] = gson.toJson(updated)
}
}
suspend fun clearRecentSearches() {
store().edit { prefs ->
prefs.remove(recentSearchesKey)
}
}
private fun parseRecentSearches(raw: String?): List<String> {
if (raw.isNullOrBlank()) return emptyList()
return try {
val type = object : TypeToken<List<String>>() {}.type
val parsed = gson.fromJson<List<String>>(raw, type).orEmpty()
normalizeRecentSearches(parsed)
} catch (_: Exception) {
emptyList()
}
}
private fun normalizeRecentSearches(items: List<String>): List<String> {
val seen = linkedSetOf<String>()
return items.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.filter { value -> seen.add(value.lowercase()) }
.toList()
}
}

View file

@ -5,7 +5,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
import javax.inject.Inject
@ -16,13 +15,8 @@ data class CachedStreamLink(
val streamName: String,
val headers: Map<String, String>,
val cachedAtMs: Long,
val filename: String? = null,
val videoHash: String? = null,
val videoSize: Long? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val sources: List<String>? = null,
val bingeGroup: String? = null
val rememberedAudioLanguage: String? = null,
val rememberedAudioName: String? = null
)
@Singleton
@ -42,26 +36,16 @@ class StreamLinkCacheDataStore @Inject constructor(
url: String,
streamName: String,
headers: Map<String, String>?,
filename: String? = null,
videoHash: String? = null,
videoSize: Long? = null,
infoHash: String? = null,
fileIdx: Int? = null,
sources: List<String>? = null,
bingeGroup: String? = null
rememberedAudioLanguage: String? = null,
rememberedAudioName: String? = null
) {
val payload = JSONObject().apply {
put("url", url)
put("streamName", streamName)
put("cachedAtMs", System.currentTimeMillis())
put("headers", JSONObject(headers ?: emptyMap<String, String>()))
put("filename", filename)
put("videoHash", videoHash)
videoSize?.let { put("videoSize", it) }
infoHash?.let { put("infoHash", it) }
fileIdx?.let { put("fileIdx", it) }
sources?.let { put("sources", JSONArray(it)) }
bingeGroup?.let { put("bingeGroup", it) }
put("rememberedAudioLanguage", rememberedAudioLanguage)
put("rememberedAudioName", rememberedAudioName)
}.toString()
store().edit { prefs ->
@ -90,30 +74,15 @@ class StreamLinkCacheDataStore @Inject constructor(
val url = json.optString("url", "")
val streamName = json.optString("streamName", "")
val infoHash = json.optString("infoHash", "").ifBlank { null }
// Accept entry if it has either a real URL (HTTP stream) or an
// infoHash (torrent stream — URL is re-resolved on each playback).
if (streamName.isBlank() || (url.isBlank() && infoHash == null)) return@runCatching null
val sourcesJson = json.optJSONArray("sources")
val sources = sourcesJson?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optString(i).takeIf { it.isNotEmpty() }
}
}?.takeIf { it.isNotEmpty() }
if (url.isBlank() || streamName.isBlank()) return@runCatching null
CachedStreamLink(
url = url,
streamName = streamName,
headers = headers,
cachedAtMs = cachedAtMs,
filename = json.optString("filename", "").ifBlank { null },
videoHash = json.optString("videoHash", "").ifBlank { null },
videoSize = json.optLong("videoSize", -1L).takeIf { it >= 0L },
infoHash = infoHash,
fileIdx = if (json.has("fileIdx")) json.optInt("fileIdx", -1).takeIf { it >= 0 } else null,
sources = sources,
bingeGroup = json.optString("bingeGroup", "").ifBlank { null }
rememberedAudioLanguage = json.optString("rememberedAudioLanguage", "").ifBlank { null },
rememberedAudioName = json.optString("rememberedAudioName", "").ifBlank { null }
)
}.getOrNull()

View file

@ -3,7 +3,6 @@ package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.domain.model.AppFont
import com.nuvio.tv.domain.model.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
@ -24,7 +23,6 @@ class ThemeDataStore @Inject constructor(
factory.get(profileId, FEATURE)
private val themeKey = stringPreferencesKey("selected_theme")
private val fontKey = stringPreferencesKey("selected_font")
val selectedTheme: Flow<AppTheme> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
@ -37,26 +35,9 @@ class ThemeDataStore @Inject constructor(
}
}
val selectedFont: Flow<AppFont> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
val fontName = prefs[fontKey] ?: AppFont.INTER.name
try {
AppFont.valueOf(fontName)
} catch (e: IllegalArgumentException) {
AppFont.INTER
}
}
}
suspend fun setTheme(theme: AppTheme) {
store().edit { prefs ->
prefs[themeKey] = theme.name
}
}
suspend fun setFont(font: AppFont) {
store().edit { prefs ->
prefs[fontKey] = font.name
}
}
}

View file

@ -24,37 +24,29 @@ class TmdbSettingsDataStore @Inject constructor(
factory.get(profileId, FEATURE)
private val enabledKey = booleanPreferencesKey("tmdb_enabled")
private val modernHomeEnabledKey = booleanPreferencesKey("tmdb_modern_home_enabled")
private val enrichContinueWatchingKey = booleanPreferencesKey("tmdb_enrich_continue_watching")
private val languageKey = stringPreferencesKey("tmdb_language")
private val useArtworkKey = booleanPreferencesKey("tmdb_use_artwork")
private val useBasicInfoKey = booleanPreferencesKey("tmdb_use_basic_info")
private val useDetailsKey = booleanPreferencesKey("tmdb_use_details")
private val useReleaseDatesKey = booleanPreferencesKey("tmdb_use_release_dates")
private val useCreditsKey = booleanPreferencesKey("tmdb_use_credits")
private val useProductionsKey = booleanPreferencesKey("tmdb_use_productions")
private val useNetworksKey = booleanPreferencesKey("tmdb_use_networks")
private val useEpisodesKey = booleanPreferencesKey("tmdb_use_episodes")
private val useMoreLikeThisKey = booleanPreferencesKey("tmdb_use_more_like_this")
private val useCollectionsKey = booleanPreferencesKey("tmdb_use_collections")
val settings: Flow<TmdbSettings> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
TmdbSettings(
enabled = prefs[enabledKey] ?: false,
modernHomeEnabled = prefs[modernHomeEnabledKey] ?: false,
enrichContinueWatching = prefs[enrichContinueWatchingKey] ?: true,
language = prefs[languageKey] ?: "en",
useArtwork = prefs[useArtworkKey] ?: true,
useBasicInfo = prefs[useBasicInfoKey] ?: true,
useDetails = prefs[useDetailsKey] ?: true,
useReleaseDates = prefs[useReleaseDatesKey] ?: true,
useCredits = prefs[useCreditsKey] ?: true,
useProductions = prefs[useProductionsKey] ?: true,
useNetworks = prefs[useNetworksKey] ?: true,
useEpisodes = prefs[useEpisodesKey] ?: true,
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true,
useCollections = prefs[useCollectionsKey] ?: true
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true
)
}
}
@ -63,14 +55,6 @@ class TmdbSettingsDataStore @Inject constructor(
store().edit { it[enabledKey] = enabled }
}
suspend fun setModernHomeEnabled(enabled: Boolean) {
store().edit { it[modernHomeEnabledKey] = enabled }
}
suspend fun setEnrichContinueWatching(enabled: Boolean) {
store().edit { it[enrichContinueWatchingKey] = enabled }
}
suspend fun setLanguage(language: String) {
store().edit { it[languageKey] = language.ifBlank { "en" } }
}
@ -87,10 +71,6 @@ class TmdbSettingsDataStore @Inject constructor(
store().edit { it[useDetailsKey] = enabled }
}
suspend fun setUseReleaseDates(enabled: Boolean) {
store().edit { it[useReleaseDatesKey] = enabled }
}
suspend fun setUseCredits(enabled: Boolean) {
store().edit { it[useCreditsKey] = enabled }
}
@ -110,8 +90,4 @@ class TmdbSettingsDataStore @Inject constructor(
suspend fun setUseMoreLikeThis(enabled: Boolean) {
store().edit { it[useMoreLikeThisKey] = enabled }
}
suspend fun setUseCollections(enabled: Boolean) {
store().edit { it[useCollectionsKey] = enabled }
}
}

View file

@ -1,120 +0,0 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TrackPreferenceDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "track_preference"
private const val SUB_TYPE = "sub_type"
private const val SUB_LANG = "sub_lang"
private const val SUB_NAME = "sub_name"
private const val SUB_TRACK_ID = "sub_track_id"
private const val SUB_ADDON_ID = "sub_addon_id"
private const val SUB_ADDON_URL = "sub_addon_url"
private const val SUB_ADDON_NAME = "sub_addon_name"
private const val AUDIO_LANG = "audio_lang"
private const val AUDIO_NAME = "audio_name"
private const val AUDIO_TRACK_ID = "audio_track_id"
}
private fun store() = factory.get(profileManager.activeProfileId.value, FEATURE)
private fun key(field: String, contentId: String) =
stringPreferencesKey("$field|$contentId")
suspend fun save(contentId: String, pref: PersistedTrackPreference) {
store().edit { prefs ->
fun set(field: String, value: String?) {
val k = key(field, contentId)
if (value != null) prefs[k] = value else prefs.remove(k)
}
set(SUB_TYPE, pref.subtitleType)
set(SUB_LANG, pref.subtitleLanguage)
set(SUB_NAME, pref.subtitleName)
set(SUB_TRACK_ID, pref.subtitleTrackId)
set(SUB_ADDON_ID, pref.addonSubtitleId)
set(SUB_ADDON_URL, pref.addonSubtitleUrl)
set(SUB_ADDON_NAME, pref.addonSubtitleAddonName)
set(AUDIO_LANG, pref.audioLanguage)
set(AUDIO_NAME, pref.audioName)
set(AUDIO_TRACK_ID, pref.audioTrackId)
}
}
suspend fun load(contentId: String): PersistedTrackPreference? {
val prefs = store().data.first()
val subType = prefs[key(SUB_TYPE, contentId)]
val audioLang = prefs[key(AUDIO_LANG, contentId)]
val audioName = prefs[key(AUDIO_NAME, contentId)]
val audioTrackId = prefs[key(AUDIO_TRACK_ID, contentId)]
if (subType == null && audioLang == null && audioName == null && audioTrackId == null) return null
return PersistedTrackPreference(
subtitleType = subType,
subtitleLanguage = prefs[key(SUB_LANG, contentId)],
subtitleName = prefs[key(SUB_NAME, contentId)],
subtitleTrackId = prefs[key(SUB_TRACK_ID, contentId)],
addonSubtitleId = prefs[key(SUB_ADDON_ID, contentId)],
addonSubtitleUrl = prefs[key(SUB_ADDON_URL, contentId)],
addonSubtitleAddonName = prefs[key(SUB_ADDON_NAME, contentId)],
audioLanguage = audioLang,
audioName = audioName,
audioTrackId = audioTrackId
)
}
}
data class PersistedTrackPreference(
val subtitleType: String?,
val subtitleLanguage: String?,
val subtitleName: String?,
val subtitleTrackId: String?,
val addonSubtitleId: String?,
val addonSubtitleUrl: String?,
val addonSubtitleAddonName: String?,
val audioLanguage: String?,
val audioName: String?,
val audioTrackId: String?
)
internal fun PersistedTrackPreference.toTrackPreference(): com.nuvio.tv.ui.screens.player.PlayerRuntimeController.TrackPreference? {
val audio = if (audioLanguage != null || audioName != null || audioTrackId != null) {
com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedTrackSelection(
language = audioLanguage,
name = audioName,
trackId = audioTrackId
)
} else null
val subtitle = when (subtitleType) {
"INTERNAL" -> com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedSubtitleSelection.Internal(
track = com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedTrackSelection(
language = subtitleLanguage,
name = subtitleName,
trackId = subtitleTrackId
)
)
"ADDON" -> com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedSubtitleSelection.Addon(
id = addonSubtitleId ?: "",
url = addonSubtitleUrl ?: "",
language = subtitleLanguage ?: "",
addonName = addonSubtitleAddonName ?: ""
)
"DISABLED" -> com.nuvio.tv.ui.screens.player.PlayerRuntimeController.RememberedSubtitleSelection.Disabled
else -> null
}
if (audio == null && subtitle == null) return null
return com.nuvio.tv.ui.screens.player.PlayerRuntimeController.TrackPreference(
audio = audio,
subtitle = subtitle
)
}

View file

@ -28,7 +28,7 @@ class TrailerSettingsDataStore @Inject constructor(
val settings: Flow<TrailerSettings> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
TrailerSettings(
enabled = prefs[enabledKey] ?: true,
enabled = prefs[enabledKey] ?: false,
delaySeconds = prefs[delaySecondsKey] ?: 7
)
}
@ -44,6 +44,6 @@ class TrailerSettingsDataStore @Inject constructor(
}
data class TrailerSettings(
val enabled: Boolean = true,
val enabled: Boolean = false,
val delaySeconds: Int = 7
)

View file

@ -1,25 +1,26 @@
package com.nuvio.tv.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.remote.dto.trakt.TraktDeviceCodeResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktTokenResponseDto
import kotlinx.coroutines.ExperimentalCoroutinesApi
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private const val TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS = 86_400
internal fun normalizeTraktTokenLifetimeSeconds(expiresIn: Int): Int {
if (expiresIn <= 0) return TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS
return expiresIn.coerceAtMost(TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS)
}
private val Context.traktAuthDataStore: DataStore<Preferences> by preferencesDataStore(
name = "trakt_auth_store"
)
data class TraktAuthState(
val accessToken: String? = null,
@ -40,15 +41,10 @@ data class TraktAuthState(
}
@Singleton
@OptIn(ExperimentalCoroutinesApi::class)
class TraktAuthDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
@ApplicationContext private val context: Context,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "trakt_auth_store"
}
private val accessTokenKey = stringPreferencesKey("access_token")
private val refreshTokenKey = stringPreferencesKey("refresh_token")
private val tokenTypeKey = stringPreferencesKey("token_type")
@ -64,44 +60,44 @@ class TraktAuthDataStore @Inject constructor(
private val expiresAtKey = longPreferencesKey("expires_at")
private val pollIntervalKey = intPreferencesKey("poll_interval")
private fun store(profileId: Int = profileManager.activeProfileId.value) =
factory.get(profileId, FEATURE)
val state: Flow<TraktAuthState> = profileManager.activeProfileId.flatMapLatest { profileId ->
store(profileId).data.map { preferences ->
TraktAuthState(
accessToken = preferences[accessTokenKey],
refreshToken = preferences[refreshTokenKey],
tokenType = preferences[tokenTypeKey],
createdAt = preferences[createdAtKey],
expiresIn = preferences[expiresInKey]?.let(::normalizeTraktTokenLifetimeSeconds),
username = preferences[usernameKey],
userSlug = preferences[userSlugKey],
deviceCode = preferences[deviceCodeKey],
userCode = preferences[userCodeKey],
verificationUrl = preferences[verificationUrlKey],
expiresAt = preferences[expiresAtKey],
pollInterval = preferences[pollIntervalKey]
)
}
val state: Flow<TraktAuthState> = context.traktAuthDataStore.data.map { preferences ->
TraktAuthState(
accessToken = preferences[accessTokenKey],
refreshToken = preferences[refreshTokenKey],
tokenType = preferences[tokenTypeKey],
createdAt = preferences[createdAtKey],
expiresIn = preferences[expiresInKey],
username = preferences[usernameKey],
userSlug = preferences[userSlugKey],
deviceCode = preferences[deviceCodeKey],
userCode = preferences[userCodeKey],
verificationUrl = preferences[verificationUrlKey],
expiresAt = preferences[expiresAtKey],
pollInterval = preferences[pollIntervalKey]
)
}
val isAuthenticated: Flow<Boolean> = state.map { it.isAuthenticated }
val isEffectivelyAuthenticated: Flow<Boolean> = isAuthenticated
val isEffectivelyAuthenticated: Flow<Boolean> = combine(
isAuthenticated,
profileManager.activeProfileId
) { authenticated, profileId ->
authenticated && profileId == 1
}
suspend fun saveToken(token: TraktTokenResponseDto) {
store().edit { preferences ->
context.traktAuthDataStore.edit { preferences ->
preferences[accessTokenKey] = token.accessToken
preferences[refreshTokenKey] = token.refreshToken
preferences[tokenTypeKey] = token.tokenType
preferences[createdAtKey] = token.createdAt
preferences[expiresInKey] = normalizeTraktTokenLifetimeSeconds(token.expiresIn)
preferences[expiresInKey] = token.expiresIn
}
}
suspend fun saveUser(username: String?, userSlug: String?) {
store().edit { preferences ->
context.traktAuthDataStore.edit { preferences ->
if (username.isNullOrBlank()) {
preferences.remove(usernameKey)
} else {
@ -117,7 +113,7 @@ class TraktAuthDataStore @Inject constructor(
suspend fun saveDeviceFlow(data: TraktDeviceCodeResponseDto) {
val now = System.currentTimeMillis()
store().edit { preferences ->
context.traktAuthDataStore.edit { preferences ->
preferences[deviceCodeKey] = data.deviceCode
preferences[userCodeKey] = data.userCode
preferences[verificationUrlKey] = data.verificationUrl
@ -127,13 +123,13 @@ class TraktAuthDataStore @Inject constructor(
}
suspend fun updatePollInterval(seconds: Int) {
store().edit { preferences ->
context.traktAuthDataStore.edit { preferences ->
preferences[pollIntervalKey] = seconds
}
}
suspend fun clearDeviceFlow() {
store().edit { preferences ->
context.traktAuthDataStore.edit { preferences ->
preferences.remove(deviceCodeKey)
preferences.remove(userCodeKey)
preferences.remove(verificationUrlKey)
@ -143,7 +139,7 @@ class TraktAuthDataStore @Inject constructor(
}
suspend fun clearAuth() {
store().edit { preferences ->
context.traktAuthDataStore.edit { preferences ->
preferences.remove(accessTokenKey)
preferences.remove(refreshTokenKey)
preferences.remove(tokenTypeKey)
@ -159,3 +155,4 @@ class TraktAuthDataStore @Inject constructor(
}
}
}

View file

@ -3,29 +3,15 @@ package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class WatchProgressSource {
TRAKT,
NUVIO_SYNC;
companion object {
fun fromStorage(value: String?): WatchProgressSource {
return entries.firstOrNull { it.name == value } ?: TRAKT
}
}
}
@Singleton
@OptIn(ExperimentalCoroutinesApi::class)
class TraktSettingsDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
@ -35,8 +21,6 @@ class TraktSettingsDataStore @Inject constructor(
const val CONTINUE_WATCHING_DAYS_CAP_ALL = 0
const val DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60
const val DEFAULT_SHOW_UNAIRED_NEXT_UP = true
const val DEFAULT_SHOW_META_COMMENTS = true
val DEFAULT_WATCH_PROGRESS_SOURCE = WatchProgressSource.TRAKT
const val MIN_CONTINUE_WATCHING_DAYS_CAP = 7
const val MAX_CONTINUE_WATCHING_DAYS_CAP = 365
}
@ -47,8 +31,6 @@ class TraktSettingsDataStore @Inject constructor(
private val continueWatchingDaysCapKey = intPreferencesKey("continue_watching_days_cap")
private val dismissedNextUpKeysKey = stringSetPreferencesKey("dismissed_next_up_keys")
private val showUnairedNextUpKey = booleanPreferencesKey("show_unaired_next_up")
private val showMetaCommentsKey = booleanPreferencesKey("show_meta_comments")
private val watchProgressSourceKey = stringPreferencesKey("watch_progress_source")
val continueWatchingDaysCap: Flow<Int> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
@ -70,18 +52,6 @@ class TraktSettingsDataStore @Inject constructor(
}
}
val showMetaComments: Flow<Boolean> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
prefs[showMetaCommentsKey] ?: DEFAULT_SHOW_META_COMMENTS
}
}
val watchProgressSource: Flow<WatchProgressSource> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
WatchProgressSource.fromStorage(prefs[watchProgressSourceKey])
}
}
suspend fun setContinueWatchingDaysCap(days: Int) {
store().edit { prefs ->
prefs[continueWatchingDaysCapKey] = normalizeContinueWatchingDaysCap(days)
@ -104,33 +74,9 @@ class TraktSettingsDataStore @Inject constructor(
}
}
suspend fun removeDismissedNextUpKeysForContent(contentId: String) {
if (contentId.isBlank()) return
val prefix = "${contentId.trim()}|"
store().edit { prefs ->
val current = prefs[dismissedNextUpKeysKey] ?: emptySet()
val filtered = current.filterNot { it.startsWith(prefix) }
if (filtered.size != current.size) {
prefs[dismissedNextUpKeysKey] = filtered.toSet()
}
}
}
suspend fun setShowUnairedNextUp(enabled: Boolean) {
store().edit { prefs ->
prefs[showUnairedNextUpKey] = enabled
}
}
suspend fun setShowMetaComments(enabled: Boolean) {
store().edit { prefs ->
prefs[showMetaCommentsKey] = enabled
}
}
suspend fun setWatchProgressSource(source: WatchProgressSource) {
store().edit { prefs ->
prefs[watchProgressSourceKey] = source.name
}
}
}

View file

@ -32,6 +32,13 @@ class WatchProgressPreferences @Inject constructor(
private val gson = Gson()
private val watchProgressKey = stringPreferencesKey("watch_progress_map")
// Maximum items to keep in continue watching
private val maxItems = 50
private val maxEpisodesPerContent = 25
private val maxStoredEntries = 300
/**
* Get all watch progress items, sorted by last watched (most recent first)
* For series, only returns the series-level entry (not individual episode entries)
@ -42,22 +49,23 @@ class WatchProgressPreferences @Inject constructor(
val json = preferences[watchProgressKey] ?: "{}"
val allItems = parseProgressMap(json)
// Group all entries by contentId and pick the most recently watched.
// When lastWatched is equal (e.g. batch mark-as-watched), prefer the highest season/episode.
val latestByContent = allItems.values
.groupBy { it.contentId }
.mapValues { (_, items) ->
items.maxWithOrNull(
compareBy<WatchProgress> { it.lastWatched }
.thenBy { it.season ?: 0 }
.thenBy { it.episode ?: 0 }
)
}
.values
.filterNotNull()
val contentLevelEntries = allItems.entries
.filter { (key, progress) -> key == progress.contentId }
.associate { it.value.contentId to it.value }
.toMutableMap()
val result = latestByContent.sortedByDescending { it.lastWatched }
result
val latestEpisodeFallbacks = allItems.values
.groupBy { it.contentId }
.mapValues { (_, items) -> items.maxByOrNull { it.lastWatched } }
latestEpisodeFallbacks.forEach { (contentId, latest) ->
if (contentLevelEntries[contentId] == null && latest != null) {
contentLevelEntries[contentId] = latest
}
}
contentLevelEntries.values
.sortedByDescending { it.lastWatched }
}
}
@ -84,10 +92,7 @@ class WatchProgressPreferences @Inject constructor(
return store().data.map { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json)
// Try direct key first (movies), then find latest episode entry (series).
map[contentId] ?: map.values
.filter { it.contentId == contentId }
.maxByOrNull { it.lastWatched }
map[contentId]
}
}
@ -124,19 +129,19 @@ class WatchProgressPreferences @Inject constructor(
store().edit { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json).toMutableMap()
upsertProgressEntries(map, listOf(progress))
val key = createKey(progress)
map[key] = progress
val pruned = pruneOldItems(map)
preferences[watchProgressKey] = gson.toJson(pruned)
}
}
if (progress.season != null && progress.episode != null) {
val seriesKey = progress.contentId
val existingSeriesProgress = map[seriesKey]
if (existingSeriesProgress == null || progress.lastWatched > existingSeriesProgress.lastWatched) {
map[seriesKey] = progress.copy(videoId = progress.videoId)
}
}
suspend fun saveProgressBatch(progressList: List<WatchProgress>) {
if (progressList.isEmpty()) return
store().edit { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json).toMutableMap()
upsertProgressEntries(map, progressList)
val pruned = pruneOldItems(map)
preferences[watchProgressKey] = gson.toJson(pruned)
}
@ -177,69 +182,17 @@ class WatchProgressPreferences @Inject constructor(
}
}
/**
* Remove watch progress for multiple episodes in a single DataStore transaction.
*/
suspend fun removeProgressBatch(contentId: String, episodes: List<Pair<Int, Int>>) {
if (episodes.isEmpty()) return
store().edit { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json).toMutableMap()
for ((season, episode) in episodes) {
map.remove("${contentId}_s${season}e${episode}")
}
map.remove(contentId)
Log.d(TAG, "removeProgressBatch contentId=$contentId removed=${episodes.size} episodes entriesAfter=${map.size}")
preferences[watchProgressKey] = gson.toJson(map)
}
}
/**
* Mark content as completed
*/
suspend fun markAsCompleted(progress: WatchProgress) {
// If the incoming duration is a dummy sentinel (≤ 1ms), check for an
// existing local entry with a real duration from prior playback.
// This creates a proper completed entry that syncs correctly cross-device.
val effectiveDuration = if (progress.duration <= 1L) {
val key = createKey(progress)
val existing = getAllRawEntries()[key]
existing?.duration?.takeIf { it > 1L } ?: progress.duration
} else {
progress.duration
}
val completedProgress = progress.copy(
position = effectiveDuration,
duration = effectiveDuration,
position = progress.duration,
lastWatched = System.currentTimeMillis()
)
saveProgress(completedProgress)
}
/**
* Mark multiple items as completed in a single DataStore transaction.
*/
suspend fun markAsCompletedBatch(progressList: List<WatchProgress>) {
if (progressList.isEmpty()) return
val rawEntries = getAllRawEntries()
val now = System.currentTimeMillis()
val completed = progressList.map { progress ->
val effectiveDuration = if (progress.duration <= 1L) {
val key = createKey(progress)
rawEntries[key]?.duration?.takeIf { it > 1L } ?: progress.duration
} else {
progress.duration
}
progress.copy(
position = effectiveDuration,
duration = effectiveDuration,
lastWatched = now
)
}
saveProgressBatch(completed)
}
/**
* Returns the raw keyWatchProgress map from DataStore (for sync push).
*/
@ -271,7 +224,7 @@ class WatchProgressPreferences @Inject constructor(
for ((key, remote) in remoteEntries) {
val existing = local[key]
if (existing == null || remote.lastWatched > existing.lastWatched) {
local[key] = mergeDisplayMetadata(remote, existing)
local[key] = remote
Log.d("WatchProgressPrefs", " merged key=$key (existing=${existing != null})")
} else {
Log.d("WatchProgressPrefs", " skipped key=$key (local is newer)")
@ -293,10 +246,7 @@ class WatchProgressPreferences @Inject constructor(
Log.w(TAG, "replaceWithRemoteEntries: remote empty while local has ${current.size} entries; preserving local watch progress")
return@edit
}
val merged = remoteEntries.mapValues { (key, remote) ->
mergeDisplayMetadata(remote, current[key])
}.toMutableMap()
val pruned = pruneOldItems(merged)
val pruned = pruneOldItems(remoteEntries.toMutableMap())
Log.d("WatchProgressPrefs", "replaceWithRemoteEntries: ${pruned.size} entries after prune, writing to DataStore")
preferences[watchProgressKey] = gson.toJson(pruned)
}
@ -319,37 +269,6 @@ class WatchProgressPreferences @Inject constructor(
}
}
private fun upsertProgressEntries(
map: MutableMap<String, WatchProgress>,
progressList: List<WatchProgress>
) {
progressList.forEach { progress ->
val key = createKey(progress)
map[key] = progress
// Remove legacy series-level mirror key if this is an episode entry.
// Mirror keys caused race conditions with stale progress data.
if (progress.season != null && progress.episode != null) {
val seriesKey = progress.contentId
if (seriesKey != key && map.containsKey(seriesKey)) {
map.remove(seriesKey)
}
}
}
}
private fun mergeDisplayMetadata(remote: WatchProgress, existing: WatchProgress?): WatchProgress {
if (existing == null) return remote
return remote.copy(
name = existing.name.takeIf { it.isNotBlank() } ?: remote.name.takeIf { it.isNotBlank() } ?: existing.name,
poster = existing.poster ?: remote.poster,
backdrop = existing.backdrop ?: remote.backdrop,
logo = existing.logo ?: remote.logo,
episodeTitle = existing.episodeTitle ?: remote.episodeTitle,
addonBaseUrl = remote.addonBaseUrl ?: existing.addonBaseUrl
)
}
private fun parseProgressMap(json: String): Map<String, WatchProgress> {
return try {
// Parse entry-by-entry so one malformed value doesn't wipe the entire map.
@ -455,6 +374,75 @@ class WatchProgressPreferences @Inject constructor(
}
private fun pruneOldItems(map: MutableMap<String, WatchProgress>): Map<String, WatchProgress> {
return map
if (map.isEmpty()) return map
val latestByContent = map.values
.groupBy { it.contentId }
.mapValues { (_, items) -> items.maxOf { it.lastWatched } }
val inProgressContentIds = map.values
.asSequence()
.filter { it.isInProgress() }
.map { it.contentId }
.toSet()
val sortedContentIds = latestByContent
.entries
.sortedByDescending { it.value }
.map { it.key }
val keepContentIds = buildList {
sortedContentIds
.filter { it in inProgressContentIds }
.forEach { add(it) }
sortedContentIds
.filter { it !in inProgressContentIds }
.forEach { add(it) }
}
.distinct()
.take(maxItems)
val keepContentIdSet = keepContentIds.toSet()
val filteredByContent = map.filterValues { it.contentId in keepContentIdSet }
val boundedByContent = mutableMapOf<String, WatchProgress>()
keepContentIds.forEach { contentId ->
val entriesForContent = filteredByContent.filterValues { it.contentId == contentId }
// Keep canonical content-level record when present.
entriesForContent[contentId]?.let { boundedByContent[contentId] = it }
val recentEpisodeEntries = entriesForContent
.filterKeys { it != contentId }
.entries
.sortedByDescending { it.value.lastWatched }
.take(maxEpisodesPerContent)
recentEpisodeEntries.forEach { (key, value) ->
boundedByContent[key] = value
}
}
if (boundedByContent.size <= maxStoredEntries) return boundedByContent
val pinnedContentKeys = keepContentIds.filter { boundedByContent.containsKey(it) }.toSet()
val remainingSlots = (maxStoredEntries - pinnedContentKeys.size).coerceAtLeast(0)
val limited = mutableMapOf<String, WatchProgress>()
pinnedContentKeys.forEach { key ->
boundedByContent[key]?.let { limited[key] = it }
}
boundedByContent.entries
.asSequence()
.filter { (key, _) -> key !in pinnedContentKeys }
.sortedByDescending { (_, value) -> value.lastWatched }
.take(remainingSlots)
.forEach { (key, value) ->
limited[key] = value
}
return limited
}
}

View file

@ -29,7 +29,7 @@ class WatchedItemsPreferences @Inject constructor(
private val gson = Gson()
private val watchedItemsKey = stringSetPreferencesKey("watched_items")
internal val allItems: Flow<List<WatchedItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
private val allItems: Flow<List<WatchedItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences ->
val raw = preferences[watchedItemsKey] ?: emptySet()
raw.mapNotNull { json ->
@ -56,13 +56,6 @@ class WatchedItemsPreferences @Inject constructor(
}
}
fun getWatchedEpisodesWithTimestamps(contentId: String): Flow<Map<Pair<Int, Int>, Long>> {
return allItems.map { items ->
items.filter { it.contentId == contentId && it.season != null && it.episode != null }
.associate { (it.season!! to it.episode!!) to it.watchedAt }
}
}
suspend fun markAsWatched(item: WatchedItem) {
store().edit { preferences ->
val current = preferences[watchedItemsKey] ?: emptySet()
@ -79,22 +72,6 @@ class WatchedItemsPreferences @Inject constructor(
}
}
suspend fun markAsWatchedBatch(items: List<WatchedItem>) {
if (items.isEmpty()) return
store().edit { preferences ->
val current = preferences[watchedItemsKey] ?: emptySet()
val newKeys = items.map { Triple(it.contentId, it.season, it.episode) }.toSet()
val filtered = current.filterNot { json ->
runCatching {
gson.fromJson(json, WatchedItem::class.java)
}.getOrNull()?.let { existing ->
Triple(existing.contentId, existing.season, existing.episode) in newKeys
} ?: false
}
preferences[watchedItemsKey] = filtered.toSet() + items.map { gson.toJson(it) }
}
}
suspend fun unmarkAsWatched(contentId: String, season: Int? = null, episode: Int? = null) {
store().edit { preferences ->
val current = preferences[watchedItemsKey] ?: emptySet()
@ -111,22 +88,6 @@ class WatchedItemsPreferences @Inject constructor(
}
}
suspend fun unmarkAsWatchedBatch(contentId: String, episodes: List<Pair<Int, Int>>) {
if (episodes.isEmpty()) return
val removeKeys = episodes.map { (s, e) -> Triple(contentId, s, e) }.toSet()
store().edit { preferences ->
val current = preferences[watchedItemsKey] ?: emptySet()
val filtered = current.filterNot { json ->
runCatching {
gson.fromJson(json, WatchedItem::class.java)
}.getOrNull()?.let { existing ->
Triple(existing.contentId, existing.season, existing.episode) in removeKeys
} ?: false
}
preferences[watchedItemsKey] = filtered.toSet()
}
}
suspend fun getAllItems(): List<WatchedItem> {
return allItems.first()
}
@ -165,10 +126,4 @@ class WatchedItemsPreferences @Inject constructor(
.toSet()
}
}
suspend fun clearAll() {
store().edit { preferences ->
preferences.remove(watchedItemsKey)
}
}
}

View file

@ -1,115 +0,0 @@
package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Shared holder for fully-watched series IDs derived from the CW pipeline.
* Updated by HomeViewModel, observed by any screen that needs series watched badges.
* Persisted per profile so badges appear instantly on cold start.
*/
@Singleton
class WatchedSeriesStateHolder @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
) {
companion object {
private const val FEATURE = "watched_series_cache"
private val KEY = stringSetPreferencesKey("fully_watched_ids")
private val TIMESTAMPS_KEY = stringPreferencesKey("validated_timestamps")
private const val VALIDATION_TTL_MS = 12L * 60 * 60 * 1000 // 12 hours
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val gson = Gson()
private val _fullyWatchedSeriesIds = MutableStateFlow<Set<String>>(emptySet())
val fullyWatchedSeriesIds: StateFlow<Set<String>> = _fullyWatchedSeriesIds.asStateFlow()
/** Per-series validation timestamps (contentId → epochMs). */
@Volatile
private var validatedAtMap: Map<String, Long> = emptyMap()
private var loaded = false
private fun store() = factory.get(profileManager.activeProfileId.value, FEATURE)
fun loadFromDisk() {
if (loaded) return
scope.launch {
val prefs = store().data.first()
val persisted = prefs[KEY] ?: emptySet()
validatedAtMap = parseTimestamps(prefs[TIMESTAMPS_KEY])
if (_fullyWatchedSeriesIds.value.isEmpty() && persisted.isNotEmpty()) {
_fullyWatchedSeriesIds.value = persisted
}
loaded = true
}
}
fun update(ids: Set<String>) {
_fullyWatchedSeriesIds.value = ids
scope.launch {
store().edit { prefs -> prefs[KEY] = ids }
}
}
/**
* Update badge IDs and mark the given series as freshly validated.
*/
fun updateWithValidation(ids: Set<String>, validatedIds: Set<String>) {
_fullyWatchedSeriesIds.value = ids
val now = System.currentTimeMillis()
val updated = validatedAtMap.toMutableMap()
validatedIds.forEach { updated[it] = now }
// Prune entries no longer in the badge set.
updated.keys.retainAll(ids)
validatedAtMap = updated
scope.launch {
store().edit { prefs ->
prefs[KEY] = ids
prefs[TIMESTAMPS_KEY] = gson.toJson(updated)
}
}
}
/**
* Returns true if the given series was validated within the TTL window.
*/
fun isSeriesValidationFresh(contentId: String): Boolean {
val ts = validatedAtMap[contentId] ?: return false
return System.currentTimeMillis() - ts < VALIDATION_TTL_MS
}
/**
* Filters the given set to only those series that need re-validation
* (not validated within TTL).
*/
fun filterStaleIds(ids: Set<String>): Set<String> {
val now = System.currentTimeMillis()
return ids.filter { id ->
val ts = validatedAtMap[id] ?: return@filter true
now - ts >= VALIDATION_TTL_MS
}.toSet()
}
private fun parseTimestamps(json: String?): Map<String, Long> {
if (json.isNullOrBlank()) return emptyMap()
return runCatching {
val type = object : TypeToken<Map<String, Long>>() {}.type
gson.fromJson<Map<String, Long>>(json, type) ?: emptyMap()
}.getOrDefault(emptyMap())
}
}

Some files were not shown because too many files have changed in this diff Show more