Compare commits

..

4 commits

Author SHA1 Message Date
tapframe
d0f978a715 catalog loading performance and cache improvement 2026-02-27 07:10:05 +05:30
tapframe
68cf2a597b improve catalog loading session 2026-02-27 06:53:45 +05:30
CrissZollo
244907a9dd
Inital load and performance updates 2026-02-26 22:09:59 +01:00
CrissZollo
c81e00c0e5
Increased the ram limit for building the app 2026-02-26 18:44:18 +01:00
400 changed files with 11166 additions and 130025 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/ db/
backups/ backups/
.supabase_db.env .supabase_db.env
scripts/* scripts
!scripts/release_beta.py supabase
/supabase
tv-samples tv-samples
HOME_UI_OPTIMIZATION_NOTES.local.md HOME_UI_OPTIMIZATION_NOTES.local.md
.kotlin/ .kotlin/
bugs/
avatars/
logs

View file

@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <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> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View file

@ -11,7 +11,6 @@
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/baselineprofile" />
</set> </set>
</option> </option>
</GradleProjectSettings> </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"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <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> </component>
</project> </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.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
@ -6,6 +6,7 @@
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
id("io.sentry.android.gradle") version "6.0.0"
} }
import java.util.Properties 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 { android {
namespace = "com.nuvio.tv" namespace = "com.nuvio.tv"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.nuvio.tv" applicationId = "com.nuvio.tv"
minSdk = 24 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 54 versionCode = 23
versionName = "0.6.3-beta" versionName = "0.4.5-beta"
buildConfigField("String", "PARENTAL_GUIDE_API_URL", "\"${localProperties.getProperty("PARENTAL_GUIDE_API_URL", "")}\"") buildConfigField("String", "PARENTAL_GUIDE_API_URL", "\"${localProperties.getProperty("PARENTAL_GUIDE_API_URL", "")}\"")
buildConfigField("String", "INTRODB_API_URL", "\"${localProperties.getProperty("INTRODB_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_ID", "\"${localProperties.getProperty("TRAKT_CLIENT_ID", "")}\"")
buildConfigField("String", "TRAKT_CLIENT_SECRET", "\"${localProperties.getProperty("TRAKT_CLIENT_SECRET", "")}\"") 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_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", "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) // In-app updater (GitHub Releases)
buildConfigField("String", "GITHUB_OWNER", "\"tapframe\"") buildConfigField("String", "GITHUB_OWNER", "\"tapframe\"")
@ -69,19 +53,20 @@ android {
signingConfigs { signingConfigs {
create("release") { create("release") {
keyAlias = releaseKeyAliasValue keyAlias = "nuviotv"
keyPassword = releaseKeyPasswordValue keyPassword = "815787"
storeFile = releaseStoreFilePath?.let(::file) ?: file("../nuviotv.jks") storeFile = file("../nuviotv.jks")
storePassword = releaseStorePasswordValue storePassword = "815787"
} }
} }
buildTypes { buildTypes {
debug { debug {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
isDebuggable = false isDebuggable = true
isMinifyEnabled = false isMinifyEnabled = false
resValue("string", "app_name", "Nuvio Debug")
buildConfigField("boolean", "IS_DEBUG_BUILD", "true") buildConfigField("boolean", "IS_DEBUG_BUILD", "true")
// Dev environment (from local.dev.properties) // Dev environment (from local.dev.properties)
@ -93,23 +78,16 @@ android {
buildConfigField("String", "TRAILER_API_URL", "\"${devProperties.getProperty("TRAILER_API_URL", "")}\"") 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_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", "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 { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = if (useDebugReleaseSigning) { signingConfig = signingConfigs.getByName("release")
signingConfigs.getByName("debug")
} else {
signingConfigs.getByName("release")
}
resValue("string", "app_name", "Nuvio")
buildConfigField("boolean", "IS_DEBUG_BUILD", "false") buildConfigField("boolean", "IS_DEBUG_BUILD", "false")
// Production environment (from local.properties) // Production environment (from local.properties)
@ -121,22 +99,6 @@ android {
buildConfigField("String", "TRAILER_API_URL", "\"${localProperties.getProperty("TRAILER_API_URL", "")}\"") 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_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", "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 +114,6 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
} }
kotlin { kotlin {
compilerOptions { compilerOptions {
@ -163,34 +124,11 @@ android {
compose = true compose = true
buildConfig = 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 { androidComponents {
onVariants(selector().withBuildType("debug")) { variant -> onVariants(selector().withBuildType("debug")) { variant ->
variant.applicationId.set("com.nuviodebug.com") variant.applicationId.set("com.nuviodebug.tv")
} }
} }
@ -207,21 +145,10 @@ configurations.all {
exclude(group = "androidx.media3", module = "media3-ui") exclude(group = "androidx.media3", module = "media3-ui")
} }
baselineProfile {
automaticGenerationDuringBuild = false
saveInSrc = true
mergeIntoMain = true
baselineProfileOutputDir = "src/main"
filter {
include("com.nuvio.tv.**")
}
}
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
val composeBom = platform("androidx.compose:compose-bom:2026.01.01") val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
baselineProfile(project(":baselineprofile")) baselineProfile(project(":benchmark"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
@ -257,7 +184,6 @@ dependencies {
// Image Loading // Image Loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.svg) implementation(libs.coil.svg)
// Navigation // Navigation
@ -296,9 +222,6 @@ dependencies {
// libass-android for ASS/SSA subtitle support (from Maven Central) // libass-android for ASS/SSA subtitle support (from Maven Central)
implementation("io.github.peerless2012:ass-media:0.4.0-beta01") 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") { implementation("dev.chrisbanes.haze:haze-android:0.7.3") {
exclude(group = "org.jetbrains.compose.ui") exclude(group = "org.jetbrains.compose.ui")
exclude(group = "org.jetbrains.compose.foundation") exclude(group = "org.jetbrains.compose.foundation")
@ -309,26 +232,6 @@ dependencies {
implementation(libs.jsoup) implementation(libs.jsoup)
implementation(libs.gson) 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 // Markdown rendering
implementation(libs.markdown.renderer.m3) implementation(libs.markdown.renderer.m3)
@ -338,7 +241,6 @@ dependencies {
implementation(libs.nanohttpd) implementation(libs.nanohttpd)
implementation(libs.zxing.core) implementation(libs.zxing.core)
// Supabase // Supabase
implementation(platform(libs.supabase.bom)) implementation(platform(libs.supabase.bom))
implementation(libs.supabase.auth) implementation(libs.supabase.auth)
@ -349,8 +251,8 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Performance profiling // Performance profiling
implementation("androidx.metrics:metrics-performance:1.0.0-rc01") // JankStats implementation("androidx.metrics:metrics-performance:1.0.0-beta01") // JankStats
debugImplementation("androidx.compose.runtime:runtime-tracing") implementation("androidx.compose.runtime:runtime-tracing") // Compose function names in Perfetto
// Bundle real crypto-js (JS) for QuickJS plugins // Bundle real crypto-js (JS) for QuickJS plugins
implementation("org.webjars.npm:crypto-js:4.2.0") 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 Retrofit service interfaces (must preserve generic return types)
-keep,allowobfuscation,allowshrinking interface retrofit2.Call -keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response -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,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Keep all project API interfaces # Keep all project API interfaces
-keep class com.nuvio.tv.data.remote.api.** { *; } -keep class com.nuvio.tv.data.remote.api.** { *; }
@ -55,9 +53,6 @@
# Keep server classes and their inner data classes (serialized with Gson) # Keep server classes and their inner data classes (serialized with Gson)
-keep class com.nuvio.tv.core.server.** { *; } -keep class com.nuvio.tv.core.server.** { *; }
# ── Torrent streaming (TorrServer) ─────────────────────────────────────────────
-keep class com.nuvio.tv.core.torrent.** { *; }
#── QuickJS ──────────────────────────────────────────────────────────────────── #── QuickJS ────────────────────────────────────────────────────────────────────
# Keep quickjs-kt library classes for proper type conversion # Keep quickjs-kt library classes for proper type conversion
-keep class com.dokar.quickjs.** { *; } -keep class com.dokar.quickjs.** { *; }
@ -88,45 +83,7 @@
kotlinx.serialization.KSerializer serializer(...); 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 ──────────────────────────────────────────────────────────────────── # ── General ────────────────────────────────────────────────────────────────────
# Keep line numbers for crash reports # Keep line numbers for crash reports
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile -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"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <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.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -22,11 +21,16 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:extractNativeLibs="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:supportsRtl="true" android:supportsRtl="true"
android:localeConfig="@xml/locale_config" android:localeConfig="@xml/locale_config"
android:theme="@style/Theme.MyApplication"> 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 <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@ -40,7 +44,6 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|uiMode|keyboard|keyboardHidden|navigation|layoutDirection|fontScale|locale"
android:theme="@style/Theme.MyApplication.Launcher"> android:theme="@style/Theme.MyApplication.Launcher">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <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.os.Bundle
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.core.os.ConfigurationCompat
import android.util.Log import android.util.Log
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.metrics.performance.JankStats import androidx.metrics.performance.JankStats
@ -28,7 +27,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -56,7 +54,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.input.key.type
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -101,7 +96,6 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.ModalNavigationDrawer import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.rememberDrawerState import androidx.tv.material3.rememberDrawerState
import com.nuvio.tv.core.profile.ProfileManager 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.LayoutPreferenceDataStore
import com.nuvio.tv.data.local.ThemeDataStore import com.nuvio.tv.data.local.ThemeDataStore
import com.nuvio.tv.data.repository.TraktProgressService 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.AppTheme
import com.nuvio.tv.domain.model.AuthState 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.ProfileSyncService
import com.nuvio.tv.core.sync.StartupSyncService 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.NuvioNavHost
import com.nuvio.tv.ui.navigation.Screen import com.nuvio.tv.ui.navigation.Screen
import com.nuvio.tv.ui.components.NuvioScrollDefaults import com.nuvio.tv.ui.components.NuvioScrollDefaults
@ -125,12 +116,14 @@ import com.nuvio.tv.ui.screens.account.AuthQrSignInScreen
import com.nuvio.tv.ui.screens.profile.ProfileSelectionScreen import com.nuvio.tv.ui.screens.profile.ProfileSelectionScreen
import com.nuvio.tv.ui.theme.NuvioColors import com.nuvio.tv.ui.theme.NuvioColors
import com.nuvio.tv.ui.theme.NuvioTheme import com.nuvio.tv.ui.theme.NuvioTheme
import com.nuvio.tv.updater.UpdateUiState
import com.nuvio.tv.updater.UpdateViewModel import com.nuvio.tv.updater.UpdateViewModel
import com.nuvio.tv.updater.ui.UpdatePromptDialog import com.nuvio.tv.updater.ui.UpdatePromptDialog
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.haze
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -141,9 +134,6 @@ import coil.request.ImageRequest
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.nuvio.tv.R import com.nuvio.tv.R
val LocalSidebarExpanded = compositionLocalOf { false }
val LocalContentFocusRequester = compositionLocalOf { FocusRequester.Default }
data class DrawerItem( data class DrawerItem(
val route: String, val route: String,
val label: String, val label: String,
@ -153,7 +143,6 @@ data class DrawerItem(
private data class MainUiPrefs( private data class MainUiPrefs(
val theme: AppTheme = AppTheme.WHITE, val theme: AppTheme = AppTheme.WHITE,
val font: AppFont = AppFont.INTER,
val hasChosenLayout: Boolean? = null, val hasChosenLayout: Boolean? = null,
val sidebarCollapsed: Boolean = false, val sidebarCollapsed: Boolean = false,
val modernSidebarEnabled: Boolean = false, val modernSidebarEnabled: Boolean = false,
@ -175,9 +164,6 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var startupSyncService: StartupSyncService lateinit var startupSyncService: StartupSyncService
@Inject
lateinit var profileSettingsSyncService: ProfileSettingsSyncService
@Inject @Inject
lateinit var profileSyncService: ProfileSyncService lateinit var profileSyncService: ProfileSyncService
@ -190,10 +176,9 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var appOnboardingDataStore: AppOnboardingDataStore lateinit var appOnboardingDataStore: AppOnboardingDataStore
@Inject
lateinit var avatarRepository: AvatarRepository
private lateinit var jankStats: JankStats private lateinit var jankStats: JankStats
private var startupSyncJob: Job? = null
private var hasDispatchedStartupSync: Boolean = false
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
override fun attachBaseContext(newBase: Context) { override fun attachBaseContext(newBase: Context) {
@ -206,9 +191,6 @@ class MainActivity : ComponentActivity() {
config.setLocale(locale) config.setLocale(locale)
super.attachBaseContext(newBase.createConfigurationContext(config)) super.attachBaseContext(newBase.createConfigurationContext(config))
} else { } else {
val systemLocale = ConfigurationCompat.getLocales(newBase.resources.configuration)[0]
?: Locale.getDefault(Locale.Category.DISPLAY)
Locale.setDefault(systemLocale)
super.attachBaseContext(newBase) super.attachBaseContext(newBase)
} }
} }
@ -217,12 +199,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) 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 { setContent {
var hasSelectedProfileThisSession by rememberSaveable { mutableStateOf(false) } var hasSelectedProfileThisSession by remember { mutableStateOf(false) }
var onboardingCompletedThisSession by remember { mutableStateOf(false) } var onboardingCompletedThisSession by remember { mutableStateOf(false) }
var onboardingProfileSyncInProgress by remember { mutableStateOf(false) } var onboardingProfileSyncInProgress by remember { mutableStateOf(false) }
val hasSeenAuthQrOnFirstLaunch by appOnboardingDataStore val hasSeenAuthQrOnFirstLaunch by appOnboardingDataStore
@ -243,63 +221,33 @@ class MainActivity : ComponentActivity() {
val activeProfile = remember(activeProfileId, profiles) { val activeProfile = remember(activeProfileId, profiles) {
profiles.firstOrNull { it.id == activeProfileId } 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) { val mainUiPrefsFlow = remember(themeDataStore, layoutPreferenceDataStore) {
combine( combine(
themeDataStore.selectedTheme, themeDataStore.selectedTheme,
themeDataStore.selectedFont,
layoutPreferenceDataStore.hasChosenLayout, layoutPreferenceDataStore.hasChosenLayout,
layoutPreferenceDataStore.sidebarCollapsedByDefault, layoutPreferenceDataStore.sidebarCollapsedByDefault,
layoutPreferenceDataStore.modernSidebarEnabled, layoutPreferenceDataStore.modernSidebarEnabled,
) { theme, font, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled -> layoutPreferenceDataStore.modernSidebarBlurEnabled
) { theme, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled, modernSidebarBlurPref ->
MainUiPrefs( MainUiPrefs(
theme = theme, theme = theme,
font = font,
hasChosenLayout = hasChosenLayout, hasChosenLayout = hasChosenLayout,
sidebarCollapsed = sidebarCollapsed, sidebarCollapsed = sidebarCollapsed,
modernSidebarEnabled = modernSidebarEnabled, modernSidebarEnabled = modernSidebarEnabled,
modernSidebarBlurPref = modernSidebarBlurPref
) )
}.combine(layoutPreferenceDataStore.modernSidebarBlurEnabled) { prefs, modernSidebarBlurPref ->
prefs.copy(modernSidebarBlurPref = modernSidebarBlurPref)
} }
} }
val mainUiPrefs by mainUiPrefsFlow.collectAsState(initial = MainUiPrefs(hasChosenLayout = null)) val mainUiPrefs by mainUiPrefsFlow.collectAsState(initial = MainUiPrefs(hasChosenLayout = null))
NuvioTheme(appTheme = mainUiPrefs.theme, appFont = mainUiPrefs.font) { NuvioTheme(appTheme = mainUiPrefs.theme) {
CompositionLocalProvider( CompositionLocalProvider(
LocalBringIntoViewSpec provides NuvioScrollDefaults.smoothScrollSpec LocalBringIntoViewSpec provides NuvioScrollDefaults.smoothScrollSpec
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
shape = RectangleShape, shape = RectangleShape
colors = SurfaceDefaults.colors(
containerColor = NuvioColors.Background
)
) { ) {
if (hasSeenAuthQrOnFirstLaunch == null) { if (hasSeenAuthQrOnFirstLaunch == null) {
Box( Box(
@ -357,7 +305,7 @@ class MainActivity : ComponentActivity() {
} }
val shouldShowProfileSelection = val shouldShowProfileSelection =
!hasSelectedProfileThisSession && (profiles.size > 1 || activeProfileHasPin) !hasSelectedProfileThisSession && profiles.size > 1
if (shouldShowProfileSelection) { if (shouldShowProfileSelection) {
ProfileSelectionScreen( ProfileSelectionScreen(
@ -386,8 +334,17 @@ class MainActivity : ComponentActivity() {
mainUiPrefs.modernSidebarBlurPref && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S mainUiPrefs.modernSidebarBlurPref && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
val hideBuiltInHeadersForFloatingPill = modernSidebarEnabled && !sidebarCollapsed val hideBuiltInHeadersForFloatingPill = modernSidebarEnabled && !sidebarCollapsed
val updateViewModel: UpdateViewModel = hiltViewModel(this@MainActivity) var shouldInitUpdateViewModel by remember { mutableStateOf(false) }
val updateState by updateViewModel.uiState.collectAsState() LaunchedEffect(Unit) {
delay(4000)
shouldInitUpdateViewModel = true
}
val updateViewModel: UpdateViewModel? = if (shouldInitUpdateViewModel) {
hiltViewModel(this@MainActivity)
} else {
null
}
val updateState = updateViewModel?.uiState?.collectAsState()?.value ?: UpdateUiState()
val startDestination = if (layoutChosen) Screen.Home.route else Screen.LayoutSelection.route val startDestination = if (layoutChosen) Screen.Home.route else Screen.LayoutSelection.route
val navController = rememberNavController() val navController = rememberNavController()
@ -471,8 +428,6 @@ class MainActivity : ComponentActivity() {
hideBuiltInHeaders = hideBuiltInHeadersForFloatingPill, hideBuiltInHeaders = hideBuiltInHeadersForFloatingPill,
activeProfileName = activeProfile?.name ?: "", activeProfileName = activeProfile?.name ?: "",
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5", activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = profiles.size > 1,
onSwitchProfile = { hasSelectedProfileThisSession = false }, onSwitchProfile = { hasSelectedProfileThisSession = false },
onExitApp = { onExitApp = {
finishAffinity() finishAffinity()
@ -491,8 +446,6 @@ class MainActivity : ComponentActivity() {
hideBuiltInHeaders = false, hideBuiltInHeaders = false,
activeProfileName = activeProfile?.name ?: "", activeProfileName = activeProfile?.name ?: "",
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5", activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = profiles.size > 1,
onSwitchProfile = { hasSelectedProfileThisSession = false }, onSwitchProfile = { hasSelectedProfileThisSession = false },
onExitApp = { onExitApp = {
finishAffinity() finishAffinity()
@ -503,11 +456,11 @@ class MainActivity : ComponentActivity() {
UpdatePromptDialog( UpdatePromptDialog(
state = updateState, state = updateState,
onDismiss = { updateViewModel.dismissDialog() }, onDismiss = { updateViewModel?.dismissDialog() },
onDownload = { updateViewModel.downloadUpdate() }, onDownload = { updateViewModel?.downloadUpdate() },
onInstall = { updateViewModel.installUpdateOrRequestPermission() }, onInstall = { updateViewModel?.installUpdateOrRequestPermission() },
onIgnore = { updateViewModel.ignoreThisVersion() }, onIgnore = { updateViewModel?.ignoreThisVersion() },
onOpenUnknownSources = { updateViewModel.openUnknownSourcesSettings() } onOpenUnknownSources = { updateViewModel?.openUnknownSourcesSettings() }
) )
} }
} }
@ -527,10 +480,6 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (::jankStats.isInitialized) jankStats.isTrackingEnabled = true if (::jankStats.isInitialized) jankStats.isTrackingEnabled = true
startupSyncService.requestSyncNow(includeProfileSettings = false)
lifecycleScope.launch {
traktProgressService.refreshNow()
}
} }
override fun onPause() { override fun onPause() {
@ -540,12 +489,23 @@ class MainActivity : ComponentActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
profileSettingsSyncService.requestForegroundPull() if (hasDispatchedStartupSync || startupSyncJob?.isActive == true) {
return
}
startupSyncJob = lifecycleScope.launch {
delay(3200)
startupSyncService.requestSyncNow()
delay(1200)
traktProgressService.refreshNow()
hasDispatchedStartupSync = true
}
} }
override fun onDestroy() { override fun onStop() {
super.onDestroy() super.onStop()
com.lagradost.cloudstream3.AcraApplication.setActivity(null) if (!hasDispatchedStartupSync && startupSyncJob?.isActive == true) {
startupSyncJob?.cancel()
}
} }
} }
@ -562,8 +522,6 @@ private fun LegacySidebarScaffold(
hideBuiltInHeaders: Boolean, hideBuiltInHeaders: Boolean,
activeProfileName: String, activeProfileName: String,
activeProfileColorHex: String, activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit, onSwitchProfile: () -> Unit,
onExitApp: () -> Unit onExitApp: () -> Unit
) { ) {
@ -578,11 +536,9 @@ private fun LegacySidebarScaffold(
} }
val closedDrawerWidth = if (sidebarCollapsed) 0.dp else 72.dp val closedDrawerWidth = if (sidebarCollapsed) 0.dp else 72.dp
val openDrawerWidth = 196.dp val openDrawerWidth = 216.dp
val focusManager = LocalFocusManager.current 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 pendingContentFocusTransfer by remember { mutableStateOf(false) }
var pendingSidebarFocusRequest by remember { mutableStateOf(false) } var pendingSidebarFocusRequest by remember { mutableStateOf(false) }
@ -600,7 +556,7 @@ private fun LegacySidebarScaffold(
return@LaunchedEffect return@LaunchedEffect
} }
repeat(2) { withFrameNanos { } } repeat(2) { withFrameNanos { } }
runCatching { contentFocusRequester.requestFocus() } focusManager.moveFocus(FocusDirection.Right)
pendingContentFocusTransfer = false pendingContentFocusTransfer = false
} }
@ -634,8 +590,7 @@ private fun LegacySidebarScaffold(
.padding(12.dp) .padding(12.dp)
.selectableGroup() .selectableGroup()
.onPreviewKeyEvent { keyEvent -> .onPreviewKeyEvent { keyEvent ->
val closeKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight if (keyEvent.key == Key.DirectionRight && keyEvent.type == KeyEventType.KeyDown) {
if (keyEvent.key == closeKey && keyEvent.type == KeyEventType.KeyDown) {
drawerState.setValue(DrawerValue.Closed) drawerState.setValue(DrawerValue.Closed)
pendingContentFocusTransfer = false pendingContentFocusTransfer = false
true true
@ -645,69 +600,21 @@ private fun LegacySidebarScaffold(
} }
) { ) {
val isExpanded = drawerValue == DrawerValue.Open val isExpanded = drawerValue == DrawerValue.Open
val itemWidth = if (isExpanded) 156.dp else 48.dp
if (isExpanded) { if (isExpanded) {
Spacer(modifier = Modifier.height(30.dp)) Spacer(modifier = Modifier.height(30.dp))
if (showProfileSelector && activeProfileName.isNotEmpty()) { Image(
var isProfileFocused by remember { mutableStateOf(false) } painter = painterResource(id = R.drawable.app_logo_wordmark),
val profileItemShape = RoundedCornerShape(32.dp) contentDescription = "NuvioTV",
val profileLeadingInset = 18.dp modifier = Modifier
val profileAvatarSize = 34.dp .fillMaxWidth()
val profileLabelStart = 60.dp .height(42.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)
)
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
val itemWidth = if (isExpanded) 176.dp else 48.dp
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
@ -737,28 +644,64 @@ private fun LegacySidebarScaffold(
} }
Spacer(modifier = Modifier.weight(1f)) 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( val contentStartPadding = if (showSidebar) closedDrawerWidth else 0.dp
targetValue = if (showSidebar) closedDrawerWidth else 0.dp,
animationSpec = tween(350),
label = "contentStartPadding"
)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(start = contentStartPadding) .padding(start = contentStartPadding)
.onKeyEvent { keyEvent -> .onKeyEvent { keyEvent ->
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
if ( if (
showSidebar && showSidebar &&
drawerState.currentValue == DrawerValue.Closed && drawerState.currentValue == DrawerValue.Closed &&
keyEvent.type == KeyEventType.KeyDown && 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 true
} else { } else {
pendingSidebarFocusRequest = true pendingSidebarFocusRequest = true
@ -770,16 +713,11 @@ private fun LegacySidebarScaffold(
} }
} }
) { ) {
CompositionLocalProvider( NuvioNavHost(
LocalSidebarExpanded provides (drawerState.currentValue == DrawerValue.Open), navController = navController,
LocalContentFocusRequester provides contentFocusRequester startDestination = startDestination,
) { hideBuiltInHeaders = hideBuiltInHeaders
NuvioNavHost( )
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
} }
} }
} }
@ -799,7 +737,7 @@ private fun LegacySidebarButton(
val backgroundColor by animateColorAsState( val backgroundColor by animateColorAsState(
targetValue = when { targetValue = when {
isFocused -> NuvioColors.FocusBackground isFocused -> NuvioColors.FocusBackground
expanded && selected -> NuvioColors.Secondary selected -> NuvioColors.BackgroundCard
else -> Color.Transparent else -> Color.Transparent
}, },
label = "legacySidebarItemBackground" label = "legacySidebarItemBackground"
@ -807,26 +745,17 @@ private fun LegacySidebarButton(
val contentColor by animateColorAsState( val contentColor by animateColorAsState(
targetValue = when { targetValue = when {
isFocused -> NuvioColors.TextPrimary isFocused -> NuvioColors.TextPrimary
expanded && selected -> NuvioColors.OnSecondary selected -> NuvioColors.TextPrimary
else -> NuvioColors.TextSecondary else -> NuvioColors.TextSecondary
}, },
label = "legacySidebarItemContent" 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( Box(
modifier = modifier modifier = modifier
.height(52.dp) .height(52.dp)
.focusProperties { canFocus = expanded } .focusProperties { canFocus = expanded }
.clip(itemShape)
.background(color = backgroundColor, shape = itemShape) .background(color = backgroundColor, shape = itemShape)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.clickable(onClick = onClick), .clickable(onClick = onClick),
@ -834,7 +763,7 @@ private fun LegacySidebarButton(
DrawerItemIcon( DrawerItemIcon(
iconRes = iconRes, iconRes = iconRes,
icon = icon, icon = icon,
tint = iconTint, tint = contentColor,
modifier = if (expanded) { modifier = if (expanded) {
Modifier Modifier
.size(22.dp) .size(22.dp)
@ -876,8 +805,6 @@ private fun ModernSidebarScaffold(
hideBuiltInHeaders: Boolean, hideBuiltInHeaders: Boolean,
activeProfileName: String, activeProfileName: String,
activeProfileColorHex: String, activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit, onSwitchProfile: () -> Unit,
onExitApp: () -> Unit onExitApp: () -> Unit
) { ) {
@ -886,8 +813,6 @@ private fun ModernSidebarScaffold(
val openSidebarWidth = 262.dp val openSidebarWidth = 262.dp
val focusManager = LocalFocusManager.current 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) { val drawerItemFocusRequesters = remember(drawerItems) {
drawerItems.associate { item -> item.route to FocusRequester() } drawerItems.associate { item -> item.route to FocusRequester() }
} }
@ -901,8 +826,6 @@ private fun ModernSidebarScaffold(
val keepFloatingPillExpanded = selectedDrawerRoute == Screen.Settings.route val keepFloatingPillExpanded = selectedDrawerRoute == Screen.Settings.route
val keepSidebarFocusDuringCollapse = val keepSidebarFocusDuringCollapse =
isSidebarExpanded || sidebarCollapsePending || pendingContentFocusTransfer isSidebarExpanded || sidebarCollapsePending || pendingContentFocusTransfer
val hasSidebarProfileItem = showProfileSelector && activeProfileName.isNotEmpty()
val sidebarTopBoundaryIndex = if (hasSidebarProfileItem) drawerItems.size else 0
LaunchedEffect(showSidebar) { LaunchedEffect(showSidebar) {
if (!showSidebar) { if (!showSidebar) {
@ -1057,7 +980,7 @@ private fun ModernSidebarScaffold(
return@LaunchedEffect return@LaunchedEffect
} }
repeat(2) { withFrameNanos { } } repeat(2) { withFrameNanos { } }
runCatching { contentFocusRequester.requestFocus() } focusManager.moveFocus(FocusDirection.Right)
pendingContentFocusTransfer = false pendingContentFocusTransfer = false
} }
@ -1082,6 +1005,13 @@ private fun ModernSidebarScaffold(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.then(
if (shouldApplySidebarHaze) {
Modifier.haze(sidebarHazeState)
} else {
Modifier
}
)
.onPreviewKeyEvent { keyEvent -> .onPreviewKeyEvent { keyEvent ->
if ( if (
isSidebarExpanded && isSidebarExpanded &&
@ -1104,9 +1034,8 @@ private fun ModernSidebarScaffold(
else -> Unit else -> Unit
} }
} }
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft if (keyEvent.key == Key.DirectionLeft) {
if (keyEvent.key == openKey) { if (focusManager.moveFocus(FocusDirection.Left)) {
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
true true
} else { } else {
isSidebarExpanded = true isSidebarExpanded = true
@ -1122,16 +1051,11 @@ private fun ModernSidebarScaffold(
} }
} }
) { ) {
CompositionLocalProvider( NuvioNavHost(
LocalSidebarExpanded provides isSidebarExpanded, navController = navController,
LocalContentFocusRequester provides contentFocusRequester startDestination = startDestination,
) { hideBuiltInHeaders = hideBuiltInHeaders
NuvioNavHost( )
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
} }
if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) { if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) {
@ -1162,22 +1086,17 @@ private fun ModernSidebarScaffold(
} }
when (keyEvent.key) { when (keyEvent.key) {
Key.DirectionUp -> { Key.DirectionUp -> {
focusedDrawerIndex == sidebarTopBoundaryIndex focusedDrawerIndex == 0
} }
Key.DirectionDown -> { Key.DirectionDown -> {
focusedDrawerIndex == drawerItems.lastIndex focusedDrawerIndex > drawerItems.lastIndex
} }
Key.DirectionRight, Key.DirectionLeft -> { Key.DirectionRight -> {
val collapseKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight pendingContentFocusTransfer = false
if (keyEvent.key == collapseKey) { sidebarCollapsePending = true
pendingContentFocusTransfer = false true
sidebarCollapsePending = true
true
} else {
false
}
} }
else -> false else -> false
@ -1212,8 +1131,6 @@ private fun ModernSidebarScaffold(
}, },
activeProfileName = activeProfileName, activeProfileName = activeProfileName,
activeProfileColorHex = activeProfileColorHex, activeProfileColorHex = activeProfileColorHex,
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = showProfileSelector,
onSwitchProfile = onSwitchProfile onSwitchProfile = onSwitchProfile
) )
} }
@ -1234,7 +1151,7 @@ private fun ModernSidebarScaffold(
.align(Alignment.TopStart) .align(Alignment.TopStart)
.offset { .offset {
IntOffset( IntOffset(
14.dp.roundToPx(), (40.dp + sidebarSlideX + sidebarDeflateOffsetX).roundToPx(),
(16.dp + sidebarDeflateOffsetY).roundToPx() (16.dp + sidebarDeflateOffsetY).roundToPx()
) )
} }
@ -1308,6 +1225,7 @@ private fun CollapsedSidebarPill(
.graphicsLayer { .graphicsLayer {
shape = pillShape shape = pillShape
clip = true clip = true
compositingStrategy = CompositingStrategy.Offscreen
} }
.clip(pillShape) .clip(pillShape)
.background(brush = pillBackgroundBrush, shape = 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.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
@ -57,10 +57,6 @@ import coil.compose.rememberAsyncImagePainter
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import coil.request.ImageRequest import coil.request.ImageRequest
private val SidebarLeadingVisualSize = 34.dp
private val SidebarContentGap = 14.dp
private val SidebarProfileContentGap = 18.dp
@Composable @Composable
internal fun ModernSidebarBlurPanel( internal fun ModernSidebarBlurPanel(
drawerItems: List<DrawerItem>, drawerItems: List<DrawerItem>,
@ -79,8 +75,6 @@ internal fun ModernSidebarBlurPanel(
onDrawerItemClick: (String) -> Unit, onDrawerItemClick: (String) -> Unit,
activeProfileName: String, activeProfileName: String,
activeProfileColorHex: String, activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit onSwitchProfile: () -> Unit
) { ) {
val delayedBlurProgress = val delayedBlurProgress =
@ -135,43 +129,19 @@ internal fun ModernSidebarBlurPanel(
.border(width = 1.dp, color = panelBorderColor, shape = panelShape) .border(width = 1.dp, color = panelBorderColor, shape = panelShape)
.padding(horizontal = 12.dp, vertical = 14.dp) .padding(horizontal = 12.dp, vertical = 14.dp)
) { ) {
if (showProfileSelector && activeProfileName.isNotEmpty()) { val headerLogoRes = if (isSidebarExpanded) R.drawable.app_logo_wordmark else R.drawable.app_logo_mark
Box( val headerLogoHeight = if (isSidebarExpanded) 42.dp else 34.dp
modifier = Modifier val headerLogoContentDescription = if (isSidebarExpanded) "NuvioTV" else "Nuvio"
.fillMaxWidth()
.offset(y = 12.dp), Image(
contentAlignment = Alignment.Center painter = painterResource(id = headerLogoRes),
) { contentDescription = headerLogoContentDescription,
SidebarProfileItem( modifier = Modifier
profileName = activeProfileName, .fillMaxWidth()
profileColorHex = activeProfileColorHex, .height(headerLogoHeight)
profileAvatarImageUrl = activeProfileAvatarImageUrl, .offset(y = 12.dp),
focusEnabled = keepSidebarFocusDuringCollapse, contentScale = ContentScale.Fit
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
)
}
}
Spacer(modifier = Modifier.height(16.dp)) 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 contentColor = if (selected) Color(0xFF10151F) else Color.White
val iconCircleColor = if (selected) Color(0xFFE7E2EF) else Color(0xFF6A6A74) val iconCircleColor = if (selected) Color(0xFFE7E2EF) else Color(0xFF6A6A74)
val iconContainerSize = 34.dp
val contentGap = 14.dp
Row( Row(
modifier = modifier modifier = modifier
.clip(shape) .clip(shape)
@ -267,7 +259,7 @@ private fun SidebarNavigationItem(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(SidebarLeadingVisualSize) .size(iconContainerSize)
.clip(CircleShape) .clip(CircleShape)
.background(iconCircleColor) .background(iconCircleColor)
.padding(6.dp) .padding(6.dp)
@ -293,7 +285,7 @@ private fun SidebarNavigationItem(
) )
} }
} }
Spacer(modifier = Modifier.width(SidebarContentGap)) Spacer(modifier = Modifier.width(contentGap))
Text( Text(
text = label, text = label,
@ -305,6 +297,7 @@ private fun SidebarNavigationItem(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.width(iconContainerSize + contentGap))
} }
} }
@ -312,7 +305,6 @@ private fun SidebarNavigationItem(
private fun SidebarProfileItem( private fun SidebarProfileItem(
profileName: String, profileName: String,
profileColorHex: String, profileColorHex: String,
profileAvatarImageUrl: String?,
focusEnabled: Boolean, focusEnabled: Boolean,
labelAlpha: Float, labelAlpha: Float,
onFocusChanged: (Boolean) -> Unit, onFocusChanged: (Boolean) -> Unit,
@ -331,6 +323,9 @@ private fun SidebarProfileItem(
animationSpec = tween(durationMillis = 180), animationSpec = tween(durationMillis = 180),
label = "profileItemBorder" label = "profileItemBorder"
) )
val contentGap = 14.dp
val iconContainerSize = 34.dp
Row( Row(
modifier = modifier modifier = modifier
.clip(shape) .clip(shape)
@ -352,29 +347,19 @@ private fun SidebarProfileItem(
.padding(horizontal = 14.dp, vertical = 10.dp), .padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( ProfileAvatarCircle(
modifier = Modifier.size(SidebarLeadingVisualSize), name = profileName,
contentAlignment = Alignment.Center colorHex = profileColorHex,
) { size = iconContainerSize
ProfileAvatarCircle( )
name = profileName, Spacer(modifier = Modifier.width(contentGap))
colorHex = profileColorHex,
size = SidebarLeadingVisualSize,
avatarImageUrl = profileAvatarImageUrl
)
}
Spacer(modifier = Modifier.width(SidebarProfileContentGap))
Text( Text(
text = profileName, text = profileName,
color = Color.White, color = Color.White,
modifier = Modifier modifier = Modifier
.weight(1f)
.graphicsLayer { alpha = labelAlpha }, .graphicsLayer { alpha = labelAlpha },
style = androidx.tv.material3.MaterialTheme.typography.titleLarge.copy( style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold maxLines = 1
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
} }

View file

@ -1,122 +1,68 @@
package com.nuvio.tv package com.nuvio.tv
import android.app.Application import android.app.Application
import android.os.Build import android.content.ComponentCallbacks2
import coil.decode.GifDecoder import android.content.res.Configuration
import coil.decode.ImageDecoderDecoder
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import android.util.Log import com.nuvio.tv.core.device.DeviceCapabilities
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.app
import com.lagradost.nicehttp.ignoreAllSSLErrors
import com.nuvio.tv.core.sync.StartupSyncService import com.nuvio.tv.core.sync.StartupSyncService
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers 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 import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class NuvioApplication : Application(), ImageLoaderFactory { class NuvioApplication : Application(), ImageLoaderFactory, ComponentCallbacks2 {
@Inject lateinit var startupSyncService: StartupSyncService @Inject lateinit var startupSyncService: StartupSyncService
@Inject lateinit var deviceCapabilities: DeviceCapabilities
companion object { private var _imageLoader: ImageLoader? = null
/**
* 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() { override fun onCreate() {
super.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 { override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this) val caps = deviceCapabilities
.components { val loader = ImageLoader.Builder(this)
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.memoryCache { .memoryCache {
MemoryCache.Builder(this) MemoryCache.Builder(this)
.maxSizePercent(0.33) .maxSizePercent(caps.memoryCachePercent)
.build() .build()
} }
.diskCache { .diskCache {
DiskCache.Builder() DiskCache.Builder()
.directory(cacheDir.resolve("image_cache")) .directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(200L * 1024 * 1024) .maxSizeBytes(caps.diskCacheSizeBytes)
.build() .build()
} }
.decoderDispatcher(Dispatchers.IO.limitedParallelism(2)) .decoderDispatcher(Dispatchers.IO.limitedParallelism(caps.decoderParallelism))
.fetcherDispatcher(Dispatchers.IO.limitedParallelism(4)) .fetcherDispatcher(Dispatchers.IO.limitedParallelism(caps.fetcherParallelism))
.bitmapFactoryMaxParallelism(2) .bitmapFactoryMaxParallelism(caps.decoderParallelism)
.allowRgb565(true) .allowRgb565(true)
.crossfade(false) .crossfade(false)
.build() .build()
_imageLoader = loader
return loader
}
@Suppress("DEPRECATION")
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
_imageLoader?.memoryCache?.clear()
}
}
override fun onLowMemory() {
super.onLowMemory()
_imageLoader?.memoryCache?.clear()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
} }
} }

View file

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

View file

@ -0,0 +1,140 @@
package com.nuvio.tv.core.device
import android.app.ActivityManager
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
enum class DeviceTier { LOW, MEDIUM, HIGH }
@Singleton
class DeviceCapabilities @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val TAG = "DeviceCapabilities"
private const val LOW_HEAP_THRESHOLD_MB = 256
private const val HIGH_HEAP_THRESHOLD_MB = 384
}
private val _tierOverride = MutableStateFlow<DeviceTier?>(null)
val tierOverride: StateFlow<DeviceTier?> = _tierOverride.asStateFlow()
private val detectedTier: DeviceTier = detectTier()
val tier: DeviceTier
get() = _tierOverride.value ?: detectedTier
fun setTierOverride(override: DeviceTier?) {
val previous = _tierOverride.value
_tierOverride.value = override
val effective = override ?: detectedTier
Log.i(TAG, "Tier override changed: ${previous ?: "auto"} -> ${override ?: "auto"} (effective=$effective, detected=$detectedTier)" +
" | poster=${tmdbPosterSize}, backdrop=${tmdbBackdropSize}, decoders=$decoderParallelism, fetchers=$fetcherParallelism" +
" | memCache=${(memoryCachePercent * 100).toInt()}%, diskCache=${diskCacheSizeBytes / 1024 / 1024}MB" +
" | keyThrottle=${keyRepeatThrottleMs}ms, prefetch=$nestedPrefetchItemCount")
}
// -- Coil image loader config --
val memoryCachePercent: Double
get() = when (tier) {
DeviceTier.LOW -> 0.15
DeviceTier.MEDIUM -> 0.20
DeviceTier.HIGH -> 0.25
}
val diskCacheSizeBytes: Long
get() = when (tier) {
DeviceTier.LOW -> 75L * 1024 * 1024
DeviceTier.MEDIUM -> 150L * 1024 * 1024
DeviceTier.HIGH -> 200L * 1024 * 1024
}
val decoderParallelism: Int
get() = when (tier) {
DeviceTier.LOW -> 1
DeviceTier.MEDIUM -> 2
DeviceTier.HIGH -> 2
}
val fetcherParallelism: Int
get() = when (tier) {
DeviceTier.LOW -> 2
DeviceTier.MEDIUM -> 3
DeviceTier.HIGH -> 4
}
// -- TMDB image sizes --
val tmdbPosterSize: String
get() = when (tier) {
DeviceTier.LOW -> "w185"
DeviceTier.MEDIUM -> "w342"
DeviceTier.HIGH -> "w500"
}
val tmdbBackdropSize: String
get() = when (tier) {
DeviceTier.LOW -> "w780"
DeviceTier.MEDIUM -> "w780"
DeviceTier.HIGH -> "w1280"
}
val tmdbProfileSize: String
get() = when (tier) {
DeviceTier.LOW -> "w185"
DeviceTier.MEDIUM -> "w342"
DeviceTier.HIGH -> "w500"
}
val tmdbLogoSize: String
get() = when (tier) {
DeviceTier.LOW -> "w185"
DeviceTier.MEDIUM -> "w300"
DeviceTier.HIGH -> "w500"
}
val tmdbStillSize: String
get() = when (tier) {
DeviceTier.LOW -> "w300"
DeviceTier.MEDIUM -> "w300"
DeviceTier.HIGH -> "w500"
}
// -- UI performance --
val keyRepeatThrottleMs: Long
get() = when (tier) {
DeviceTier.LOW -> 140L
DeviceTier.MEDIUM -> 80L
DeviceTier.HIGH -> 80L
}
val nestedPrefetchItemCount: Int
get() = when (tier) {
DeviceTier.LOW -> 0
DeviceTier.MEDIUM -> 2
DeviceTier.HIGH -> 2
}
private fun detectTier(): DeviceTier {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val isLowRam = activityManager.isLowRamDevice
val maxHeapMb = (Runtime.getRuntime().maxMemory() / (1024 * 1024)).toInt()
val tier = when {
isLowRam || maxHeapMb < LOW_HEAP_THRESHOLD_MB -> DeviceTier.LOW
maxHeapMb < HIGH_HEAP_THRESHOLD_MB -> DeviceTier.MEDIUM
else -> DeviceTier.HIGH
}
Log.i(TAG, "Device tier: $tier (lowRam=$isLowRam, maxHeap=${maxHeapMb}MB)")
return tier
}
}

View file

@ -1,13 +1,10 @@
package com.nuvio.tv.core.di package com.nuvio.tv.core.di
import android.content.Context import android.content.Context
import android.util.Log
import com.nuvio.tv.BuildConfig import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.AddonApi import com.nuvio.tv.data.remote.api.AddonApi
import com.nuvio.tv.data.remote.api.AniSkipApi 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.ArmApi
import com.nuvio.tv.data.remote.api.DonationsApi
import com.nuvio.tv.data.remote.api.GitHubReleaseApi import com.nuvio.tv.data.remote.api.GitHubReleaseApi
import com.nuvio.tv.data.remote.api.TraktApi import com.nuvio.tv.data.remote.api.TraktApi
import com.nuvio.tv.data.remote.api.TrailerApi import com.nuvio.tv.data.remote.api.TrailerApi
@ -29,22 +26,10 @@ import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import com.nuvio.tv.core.network.IPv4FirstDns
import java.io.File import java.io.File
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Named import javax.inject.Named
import javax.inject.Singleton 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)
fun nextRequestId(): Long = requestCounter.incrementAndGet()
}
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -58,39 +43,15 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient { fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient = OkHttpClient.Builder()
val trustAllManager = object : X509TrustManager { .cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit .connectTimeout(30, TimeUnit.SECONDS)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit .readTimeout(30, TimeUnit.SECONDS)
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray() .addInterceptor(HttpLoggingInterceptor().apply {
} level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
val sslContext = SSLContext.getInstance("TLS").apply { else HttpLoggingInterceptor.Level.NONE
init(null, arrayOf<TrustManager>(trustAllManager), SecureRandom()) })
} .build()
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()
}
@Provides @Provides
@Singleton @Singleton
@ -103,53 +64,11 @@ object NetworkModule {
val version = BuildConfig.VERSION_NAME.ifBlank { "dev" } val version = BuildConfig.VERSION_NAME.ifBlank { "dev" }
val newRequest = request.newBuilder() val newRequest = request.newBuilder()
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("User-Agent", "Nuvio/$version") .header("User-Agent", "NuvioTV/$version")
.header("trakt-api-key", BuildConfig.TRAKT_CLIENT_ID) .header("trakt-api-key", BuildConfig.TRAKT_CLIENT_ID)
.header("trakt-api-version", "2") .header("trakt-api-version", "2")
.build() .build()
chain.proceed(newRequest)
if (!BuildConfig.DEBUG) {
return@addInterceptor chain.proceed(newRequest)
}
val requestId = TraktHttpTrace.nextRequestId()
val target = buildString {
append(newRequest.url.encodedPath)
newRequest.url.encodedQuery?.let { query ->
append('?')
append(query)
}
}
val startNs = System.nanoTime()
Log.d("TraktHttp", "REQ #$requestId ${newRequest.method} $target")
try {
val response = chain.proceed(newRequest)
val durationMs = (System.nanoTime() - startNs) / 1_000_000L
val retryAfter = response.header("Retry-After")
val rateLimit = response.header("X-Ratelimit")
val page = response.header("X-Pagination-Page")
val pageCount = response.header("X-Pagination-Page-Count")
val pageInfo = if (page != null || pageCount != null) {
" page=${page ?: "-"} pageCount=${pageCount ?: "-"}"
} else {
""
}
val retryInfo = retryAfter?.let { " retryAfter=${it}s" } ?: ""
val rateInfo = rateLimit?.let { " rate=$it" } ?: ""
Log.d(
"TraktHttp",
"RES #$requestId ${response.code} ${newRequest.method} $target ${durationMs}ms$retryInfo$pageInfo$rateInfo"
)
response
} catch (error: Exception) {
val durationMs = (System.nanoTime() - startNs) / 1_000_000L
Log.w(
"TraktHttp",
"ERR #$requestId ${newRequest.method} $target ${durationMs}ms ${error.javaClass.simpleName}: ${error.message}"
)
throw error
}
} }
.build() .build()
@ -262,21 +181,6 @@ object NetworkModule {
fun provideArmApi(@Named("arm") retrofit: Retrofit): ArmApi = fun provideArmApi(@Named("arm") retrofit: Retrofit): ArmApi =
retrofit.create(ArmApi::class.java) 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) --- // --- GitHub Releases API (in-app updates) ---
@Provides @Provides
@ -294,26 +198,6 @@ object NetworkModule {
fun provideGitHubReleaseApi(@Named("github") retrofit: Retrofit): GitHubReleaseApi = fun provideGitHubReleaseApi(@Named("github") retrofit: Retrofit): GitHubReleaseApi =
retrofit.create(GitHubReleaseApi::class.java) 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 --- // --- Trailer API ---
@Provides @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

@ -0,0 +1,33 @@
package com.nuvio.tv.core.image
import com.nuvio.tv.core.device.DeviceCapabilities
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TmdbImageUrlOptimizer @Inject constructor(
private val deviceCapabilities: DeviceCapabilities
) {
companion object {
private val TMDB_IMAGE_URL_REGEX =
Regex("""https?://image\.tmdb\.org/t/p/(w\d+|original)(/[^?#\s]+)""")
}
fun optimizePosterUrl(url: String?): String? {
url ?: return null
return replaceSize(url, deviceCapabilities.tmdbPosterSize)
}
fun optimizeBackdropUrl(url: String?): String? {
url ?: return null
return replaceSize(url, deviceCapabilities.tmdbBackdropSize)
}
private fun replaceSize(url: String, targetSize: String): String {
val match = TMDB_IMAGE_URL_REGEX.find(url) ?: return url
val currentSize = match.groupValues[1]
if (currentSize == targetSize) return url
val path = match.groupValues[2]
return "https://image.tmdb.org/t/p/$targetSize$path"
}
}

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

View file

@ -2,23 +2,18 @@ package com.nuvio.tv.core.player
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.hardware.display.DisplayManager
import android.media.MediaExtractor import android.media.MediaExtractor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Display 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.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Locale
/** /**
* Auto frame rate matching utility. * Auto frame rate matching utility.
@ -27,29 +22,28 @@ import java.util.Locale
object FrameRateUtils { object FrameRateUtils {
private const val TAG = "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 REFRESH_MATCH_MIN_TOLERANCE_HZ = 0.08f
private const val NTSC_FILM_FPS = 24000f / 1001f private const val NTSC_FILM_FPS = 24000f / 1001f
private const val CINEMA_24_FPS = 24f 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( 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 private var originalModeId: Int? = null
data class FrameRateDetection( data class FrameRateDetection(
val raw: Float, val raw: Float,
val snapped: Float, val snapped: Float
val videoWidth: Int? = null,
val videoHeight: Int? = null
) )
private fun matchesTargetRefresh(refreshRate: Float, target: Float): Boolean { private fun matchesTargetRefresh(refreshRate: Float, target: Float): Boolean {
@ -78,6 +72,35 @@ object FrameRateUtils {
return weight 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) { private fun recordOriginalMode(display: Display) {
if (originalModeId == null) { if (originalModeId == null) {
originalModeId = display.mode.modeId originalModeId = display.mode.modeId
@ -128,153 +151,109 @@ object FrameRateUtils {
} }
} }
private fun chooseBestModeForFrameRate( fun matchFrameRate(
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(
activity: Activity, activity: Activity,
frameRate: Float, frameRate: Float,
videoWidth: Int? = null, onBeforeSwitch: (() -> Unit)? = null,
videoHeight: Int? = null, onAfterSwitch: ((DisplayModeSwitchResult) -> Unit)? = null
resolutionMatchingEnabled: Boolean = false ): Boolean {
): DisplayModeSwitchResult? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null if (frameRate <= 0f) return false
if (frameRate <= 0f) return null
val switchPlan = withContext(Dispatchers.Main) { return try {
val window = activity.window ?: return@withContext null val window = activity.window ?: return false
val display = window.decorView.display ?: return@withContext null val display = window.decorView.display ?: return false
val supportedModes = display.supportedModes
val activeMode = display.mode val activeMode = display.mode
val sameSizeModes = display.supportedModes.filter { if (supportedModes.size <= 1) return false
val sameSizeModes = supportedModes.filter {
it.physicalWidth == activeMode.physicalWidth && it.physicalWidth == activeMode.physicalWidth &&
it.physicalHeight == activeMode.physicalHeight it.physicalHeight == activeMode.physicalHeight
} }
val candidateModes = if (resolutionMatchingEnabled && hasValidVideoSize(videoWidth, videoHeight)) { if (sameSizeModes.size <= 1) return false
selectModesForVideoResolution(
modes = display.supportedModes.toList(), // Kodi-like priority without user whitelist:
videoWidth = videoWidth ?: activeMode.physicalWidth, // exact -> 2x -> 3:2 pulldown -> weighted fallback.
videoHeight = videoHeight ?: activeMode.physicalHeight 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( cleanupDisplayListener()
activeMode = activeMode, recordOriginalMode(display)
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)
)
}
Log.d( var completeImmediately = false
TAG, if (onAfterSwitch != null) {
"Switching display mode: ${activeMode.refreshRate}Hz -> ${modeBest.refreshRate}Hz " + pendingAfterSwitch = onAfterSwitch
"(video ${frameRate}fps)" 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 onBeforeSwitch?.invoke()
layoutParams.preferredDisplayModeId = modeBest.modeId
window.attributes = layoutParams
Pair(modeBest, null)
} ?: return null
val immediateResult = switchPlan.second val layoutParams = window.attributes
if (immediateResult != null) return immediateResult layoutParams.preferredDisplayModeId = modeBest.modeId
window.attributes = layoutParams
val expectedMode = switchPlan.first ?: return null if (completeImmediately) {
var stablePolls = 0 completeSwitch("noDisplayManager")
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)
} }
} else { } 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 switchNeeded
Log.w( } catch (e: Exception) {
TAG, Log.e(TAG, "Failed to match frame rate", e)
"Display mode polling timed out after ${SWITCH_TIMEOUT_MS}ms, using ${fallbackMode.refreshRate}Hz" if (pendingAfterSwitch != null) {
) completeSwitch("error")
return DisplayModeSwitchResult(fallbackMode) }
false
}
} }
fun cleanupDisplayListener() { 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() { fun clearOriginalDisplayMode() {
@ -337,93 +316,10 @@ object FrameRateUtils {
return snapToStandardRate(measuredFps) return snapToStandardRate(measuredFps)
} }
private fun isValidVideoFrameRate(frameRate: Float): Boolean {
return frameRate.isFinite() && frameRate in MIN_VALID_VIDEO_FPS..MAX_VALID_VIDEO_FPS
}
fun detectFrameRateFromSource( fun detectFrameRateFromSource(
context: Context, context: Context,
sourceUrl: String, sourceUrl: String,
headers: Map<String, String> = emptyMap() 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? { ): FrameRateDetection? {
val extractor = MediaExtractor() val extractor = MediaExtractor()
return try { return try {
@ -434,45 +330,19 @@ object FrameRateUtils {
} }
var videoTrackIndex = -1 var videoTrackIndex = -1
var videoFormat: android.media.MediaFormat? = null
for (i in 0 until extractor.trackCount) { for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i) val format = extractor.getTrackFormat(i)
val mime = format.getString(android.media.MediaFormat.KEY_MIME) val mime = format.getString(android.media.MediaFormat.KEY_MIME)
if (mime?.startsWith("video/") == true) { if (mime?.startsWith("video/") == true) {
videoTrackIndex = i videoTrackIndex = i
videoFormat = format
break break
} }
} }
if (videoTrackIndex < 0) return null 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) extractor.selectTrack(videoTrackIndex)
val timestamps = ArrayList<Long>(400) val timestamps = ArrayList<Long>(400)
val ignoreSamples = 3 val ignoreSamples = 30
val targetSamples = 350 + ignoreSamples val targetSamples = 350 + ignoreSamples
while (timestamps.size < targetSamples) { while (timestamps.size < targetSamples) {
@ -490,19 +360,17 @@ object FrameRateUtils {
} }
val sampleCount = (timestamps.size - ignoreSamples - 1).coerceAtLeast(1) val sampleCount = (timestamps.size - ignoreSamples - 1).coerceAtLeast(1)
if (sampleCount < 30) return null if (sampleCount < 90) return null
val averageFrameDurationUs = totalFrameDurationUs.toFloat() / sampleCount.toFloat() val averageFrameDurationUs = totalFrameDurationUs.toFloat() / sampleCount.toFloat()
if (averageFrameDurationUs <= 0f) return null if (averageFrameDurationUs <= 0f) return null
val measured = 1_000_000f / averageFrameDurationUs val measured = 1_000_000f / averageFrameDurationUs
if (!isValidVideoFrameRate(measured)) return null if (measured < 10f || measured > 120f) return null
FrameRateDetection( FrameRateDetection(
raw = measured, raw = measured,
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs), snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs)
videoWidth = detectedVideoWidth,
videoHeight = detectedVideoHeight
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Frame rate probe failed: ${e.message}") 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 return orderedAddons + pluginEntries
} }
private fun isPlayable(stream: Stream): Boolean =
stream.getStreamUrl() != null || stream.isTorrent()
fun selectAutoPlayStream( fun selectAutoPlayStream(
streams: List<Stream>, streams: List<Stream>,
mode: StreamAutoPlayMode, mode: StreamAutoPlayMode,
@ -30,8 +25,7 @@ object StreamAutoPlaySelector {
installedAddonNames: Set<String>, installedAddonNames: Set<String>,
selectedAddons: Set<String>, selectedAddons: Set<String>,
selectedPlugins: Set<String>, selectedPlugins: Set<String>,
preferredBingeGroup: String? = null, preferredBingeGroup: String? = null
preferBingeGroupInSelection: Boolean = false
): Stream? { ): Stream? {
if (streams.isEmpty()) return null if (streams.isEmpty()) return null
@ -52,22 +46,22 @@ object StreamAutoPlaySelector {
if (mode == StreamAutoPlayMode.MANUAL) return null if (mode == StreamAutoPlayMode.MANUAL) return null
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { if (targetBingeGroup.isNotEmpty()) {
val bingeGroupMatch = candidateStreams.firstOrNull { stream -> val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
stream.behaviorHints?.bingeGroup == targetBingeGroup && isPlayable(stream) stream.behaviorHints?.bingeGroup == targetBingeGroup && stream.getStreamUrl() != null
} }
if (bingeGroupMatch != null) return bingeGroupMatch if (bingeGroupMatch != null) return bingeGroupMatch
} }
return when (mode) { return when (mode) {
StreamAutoPlayMode.MANUAL -> null StreamAutoPlayMode.MANUAL -> null
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { isPlayable(it) } StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.getStreamUrl() != null }
StreamAutoPlayMode.REGEX_MATCH -> { StreamAutoPlayMode.REGEX_MATCH -> {
val pattern = regexPattern.trim() val pattern = regexPattern.trim()
if (pattern.isBlank()) return null
// Try to compile the user regex // Try to compile the user regex
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull() val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull() ?: return null
if (userRegex == null) return null
// Auto-extract exclusion patterns from negative lookaheads // Auto-extract exclusion patterns from negative lookaheads
val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern) val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern)
@ -82,34 +76,31 @@ object StreamAutoPlaySelector {
Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE) Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE)
} else null } else null
// 1. Build list of ALL regexmatching streams
val matchingStreams = candidateStreams.filter { stream -> candidateStreams.firstOrNull { stream ->
if (!isPlayable(stream)) return@filter false val url = stream.getStreamUrl() ?: return@firstOrNull false
val searchableText = buildString { val searchableText = buildString {
append(stream.addonName).append(' ') append(stream.addonName).append(' ')
append(stream.name.orEmpty()).append(' ') append(stream.name.orEmpty()).append(' ')
append(stream.title.orEmpty()).append(' ') append(stream.title.orEmpty()).append(' ')
append(stream.description.orEmpty()).append(' ') append(stream.description.orEmpty()).append(' ')
append(stream.getStreamUrl().orEmpty()) append(url)
if (stream.isTorrent()) append(' ').append(stream.infoHash.orEmpty())
} }
// Must match user include pattern
if (!userRegex.containsMatchIn(searchableText)) return@firstOrNull false
// Must match include pattern // Must NOT match user exclusion pattern (if any)
if (!userRegex.containsMatchIn(searchableText)) return@filter false
// Must NOT match exclusion pattern
if (excludeRegex != null && excludeRegex.containsMatchIn(searchableText)) { if (excludeRegex != null && excludeRegex.containsMatchIn(searchableText)) {
return@filter false return@firstOrNull false
} }
true true
} }
if (matchingStreams.isEmpty()) return null
matchingStreams.firstOrNull { isPlayable(it) }
} }
} }
} }
} }

View file

@ -1,27 +1,16 @@
package com.nuvio.tv.core.plugin package com.nuvio.tv.core.plugin
import android.util.Log 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.data.local.PluginDataStore
import com.nuvio.tv.domain.model.ExternalPluginEntry
import com.nuvio.tv.domain.model.LocalScraperResult import com.nuvio.tv.domain.model.LocalScraperResult
import com.nuvio.tv.domain.model.PluginManifest import com.nuvio.tv.domain.model.PluginManifest
import com.nuvio.tv.domain.model.PluginRepository 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.ScraperInfo
import com.nuvio.tv.domain.model.ScraperManifestInfo import com.nuvio.tv.domain.model.ScraperManifestInfo
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -33,7 +22,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.security.MessageDigest import java.security.MessageDigest
@ -47,7 +35,6 @@ private const val TAG = "PluginManager"
private const val MAX_CONCURRENT_SCRAPERS = 5 private const val MAX_CONCURRENT_SCRAPERS = 5
private const val MAX_RESULT_ITEMS = 150 private const val MAX_RESULT_ITEMS = 150
private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L
private const val SCRAPER_TIMEOUT_MS = 30_000L
private const val MANIFEST_SUFFIX = "/manifest.json" private const val MANIFEST_SUFFIX = "/manifest.json"
@Singleton @Singleton
@ -55,10 +42,7 @@ class PluginManager @Inject constructor(
private val dataStore: PluginDataStore, private val dataStore: PluginDataStore,
private val runtime: PluginRuntime, private val runtime: PluginRuntime,
private val pluginSyncService: com.nuvio.tv.core.sync.PluginSyncService, private val pluginSyncService: com.nuvio.tv.core.sync.PluginSyncService,
private val authManager: com.nuvio.tv.core.auth.AuthManager, private val authManager: com.nuvio.tv.core.auth.AuthManager
private val externalRepoParser: ExternalRepoParser,
private val externalExtensionLoader: ExternalExtensionLoader,
private val externalExtensionRunner: ExternalExtensionRunner
) { ) {
private val moshi = Moshi.Builder() private val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory()) .addLast(KotlinJsonAdapterFactory())
@ -67,7 +51,6 @@ class PluginManager @Inject constructor(
private val manifestAdapter = moshi.adapter(PluginManifest::class.java) private val manifestAdapter = moshi.adapter(PluginManifest::class.java)
private val httpClient = OkHttpClient.Builder() private val httpClient = OkHttpClient.Builder()
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
@ -82,96 +65,8 @@ class PluginManager @Inject constructor(
return sb.toString() 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 { private fun canonicalizeManifestUrl(url: String): String {
val trimmed = sanitizeScheme(url).trimEnd('/') val trimmed = url.trim().trimEnd('/')
return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) { return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) {
trimmed trimmed
} else { } else {
@ -179,21 +74,7 @@ class PluginManager @Inject constructor(
} }
} }
/** private fun normalizeUrl(url: String): String = canonicalizeManifestUrl(url).lowercase()
* 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()
// Single-flight map to prevent duplicate scraper executions // Single-flight map to prevent duplicate scraper executions
private val inFlightScrapers = ConcurrentHashMap<String, kotlinx.coroutines.Deferred<List<LocalScraperResult>>>() private val inFlightScrapers = ConcurrentHashMap<String, kotlinx.coroutines.Deferred<List<LocalScraperResult>>>()
@ -216,30 +97,11 @@ class PluginManager @Inject constructor(
var isSyncingFromRemote = false 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 var syncJob: kotlinx.coroutines.Job? = null
private fun triggerRemoteSync() { private fun triggerRemoteSync() {
if (isSyncingFromRemote) { if (isSyncingFromRemote) {
Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote), will push after sync") Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote)")
pendingPushAfterSync = true
return return
} }
if (!authManager.isAuthenticated) { if (!authManager.isAuthenticated) {
@ -264,205 +126,73 @@ class PluginManager @Inject constructor(
} }
/** /**
* Add a new repository from manifest URL. * Add a new repository from manifest URL
* Auto-detects format: tries NuvioTV manifest first, then external repo format.
*/ */
suspend fun addRepository(manifestUrl: String): Result<PluginRepository> = withContext(Dispatchers.IO) { suspend fun addRepository(manifestUrl: String): Result<PluginRepository> = withContext(Dispatchers.IO) {
try { try {
// Resolve short codes (e.g. "cspr", "0094") via cutt.ly redirect val canonicalManifestUrl = canonicalizeManifestUrl(manifestUrl)
val resolvedUrl = if (isShortCode(manifestUrl)) { Log.d(TAG, "Adding repository from: $canonicalManifestUrl")
Log.d(TAG, "Input looks like a short code: '$manifestUrl'")
resolveShortCode(manifestUrl.trim()) // Fetch manifest
?: 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 manifest = fetchManifest(canonicalManifestUrl) val manifest = fetchManifest(canonicalManifestUrl)
if (manifest != null) { ?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
return@withContext addNuvioRepository(canonicalManifestUrl, 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) { } catch (e: Exception) {
Log.e(TAG, "Failed to add repository: ${e.message}", e) Log.e(TAG, "Failed to add repository: ${e.message}", e)
Result.failure(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 * Remove a repository and its scrapers
*/ */
suspend fun removeRepository(repoId: String) { suspend fun removeRepository(repoId: String) {
val scraperList = dataStore.scrapers.first() val scraperList = dataStore.scrapers.first()
val repo = dataStore.repositories.first().find { it.id == repoId }
// Remove all scrapers from this repo // Remove all scrapers from this repo
scraperList.filter { it.repositoryId == repoId }.forEach { scraper -> scraperList.filter { it.repositoryId == repoId }.forEach { scraper ->
if (scraper.type == RepositoryType.EXTERNAL_DEX || repo?.type == RepositoryType.EXTERNAL_DEX) { dataStore.deleteScraperCode(scraper.id)
externalExtensionLoader.deleteExtension(scraper.id)
} else {
dataStore.deleteScraperCode(scraper.id)
}
} }
// Remove scrapers from list // Remove scrapers from list
val updatedScrapers = scraperList.filter { it.repositoryId != repoId } val updatedScrapers = scraperList.filter { it.repositoryId != repoId }
dataStore.saveScrapers(updatedScrapers) dataStore.saveScrapers(updatedScrapers)
// Remove repository // Remove repository
dataStore.removeRepository(repoId) dataStore.removeRepository(repoId)
triggerRemoteSync()
// 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
}
} }
/**
* 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( suspend fun reconcileWithRemoteRepoUrls(
remotePlugins: List<RemotePluginInfo>, remoteUrls: List<String>,
removeMissingLocal: Boolean = true removeMissingLocal: Boolean = true
) = reconcileMutex.withLock { ) {
val normalizedRemote = remotePlugins val normalizedRemote = remoteUrls
.map { it.copy(url = canonicalizeRepoUrl(it.url)) } .map { canonicalizeManifestUrl(it) }
.filter { it.url.isNotEmpty() } .filter { it.isNotEmpty() }
.distinctBy { normalizeUrl(it.url) } .distinctBy { normalizeUrl(it) }
val remoteUrlSet = normalizedRemote.map { normalizeUrl(it.url) }.toSet() val remoteUrlSet = normalizedRemote.map { normalizeUrl(it) }.toSet()
val initialLocalRepos = dataStore.repositories.first() val initialLocalRepos = dataStore.repositories.first()
val initialLocalByNormalizedUrl = initialLocalRepos.associateBy { normalizeUrl(it.url) } val initialLocalByNormalizedUrl = initialLocalRepos.associateBy { normalizeUrl(it.url) }
@ -479,28 +209,19 @@ class PluginManager @Inject constructor(
if (shouldRemoveMissingLocal) { if (shouldRemoveMissingLocal) {
initialLocalRepos initialLocalRepos
.filter { normalizeUrl(it.url) !in remoteUrlSet } .filter { normalizeUrl(it.url) !in remoteUrlSet }
.forEach { repo -> .forEach { repo -> removeRepository(repo.id) }
Log.d(TAG, "reconcile: removing local repo not in remote: ${repo.name} (${repo.url})")
removeRepository(repo.id)
}
} }
normalizedRemote.forEach { remotePlugin -> normalizedRemote.forEach { remoteUrl ->
if (initialLocalByNormalizedUrl[normalizeUrl(remotePlugin.url)] == null) { if (initialLocalByNormalizedUrl[normalizeUrl(remoteUrl)] == null) {
val typeHint = remotePlugin.repoType?.let { addRepository(remoteUrl)
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}")
}
} }
} }
val currentRepos = dataStore.repositories.first() val currentRepos = dataStore.repositories.first()
val currentByNormalizedUrl = currentRepos.associateBy { normalizeUrl(it.url) } val currentByNormalizedUrl = currentRepos.associateBy { normalizeUrl(it.url) }
val remoteOrderedRepos = normalizedRemote val remoteOrderedRepos = normalizedRemote
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it.url)] } .mapNotNull { currentByNormalizedUrl[normalizeUrl(it)] }
val extras = currentRepos val extras = currentRepos
.filter { normalizeUrl(it.url) !in remoteUrlSet } .filter { normalizeUrl(it.url) !in remoteUrlSet }
@ -509,18 +230,6 @@ class PluginManager @Inject constructor(
dataStore.saveRepositories(reordered) 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 * Refresh a repository - re-download manifest and scrapers
@ -529,14 +238,10 @@ class PluginManager @Inject constructor(
try { try {
val repo = dataStore.repositories.first().find { it.id == repoId } val repo = dataStore.repositories.first().find { it.id == repoId }
?: return@withContext Result.failure(Exception("Repository not found")) ?: return@withContext Result.failure(Exception("Repository not found"))
if (repo.type == RepositoryType.EXTERNAL_DEX) {
return@withContext refreshExternalRepository(repo)
}
val manifest = fetchManifest(repo.url) val manifest = fetchManifest(repo.url)
?: return@withContext Result.failure(Exception("Failed to fetch manifest")) ?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
// Update repository // Update repository
val updatedRepo = repo.copy( val updatedRepo = repo.copy(
name = manifest.name, name = manifest.name,
@ -544,36 +249,16 @@ class PluginManager @Inject constructor(
scraperCount = manifest.scrapers.size scraperCount = manifest.scrapers.size
) )
dataStore.updateRepository(updatedRepo) dataStore.updateRepository(updatedRepo)
// Re-download scrapers // Re-download scrapers
downloadJsScrapers(repo.id, repo.url, manifest.scrapers) downloadScrapers(repo.id, repo.url, manifest.scrapers)
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to refresh repository: ${e.message}", e) Log.e(TAG, "Failed to refresh repository: ${e.message}", e)
Result.failure(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 * Toggle scraper enabled state
@ -585,18 +270,7 @@ class PluginManager @Inject constructor(
} }
dataStore.saveScrapers(updatedScrapers) 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 * Toggle plugins globally enabled
*/ */
@ -625,20 +299,7 @@ class PluginManager @Inject constructor(
} }
Log.d(TAG, "Executing ${enabledScraperList.size} scrapers for $mediaType:$tmdbId") 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 -> val results = enabledScraperList.map { scraper ->
async { async {
executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode) 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") 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 // Launch all scrapers concurrently within the channelFlow scope
enabledList.forEach { scraper -> enabledList.forEach { scraper ->
launch { launch {
@ -737,7 +389,7 @@ class PluginManager @Inject constructor(
} }
/** /**
* Execute a single scraper, dispatching by type. * Execute a single scraper
*/ */
suspend fun executeScraper( suspend fun executeScraper(
scraper: ScraperInfo, scraper: ScraperInfo,
@ -745,19 +397,6 @@ class PluginManager @Inject constructor(
mediaType: String, mediaType: String,
season: Int?, season: Int?,
episode: 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> { ): List<LocalScraperResult> {
return try { return try {
val code = dataStore.getScraperCode(scraper.id) val code = dataStore.getScraperCode(scraper.id)
@ -779,108 +418,45 @@ class PluginManager @Inject constructor(
} catch (_: Exception) { } catch (_: Exception) {
// ignore // ignore
} }
val settings = dataStore.getScraperSettings(scraper.id) val settings = dataStore.getScraperSettings(scraper.id)
Log.d(TAG, "Executing scraper: ${scraper.name}") Log.d(TAG, "Executing scraper: ${scraper.name}")
val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) { val results = runtime.executePlugin(
withContext(Dispatchers.IO) { code = code,
runtime.executePlugin( tmdbId = tmdbId,
code = code, mediaType = mediaType,
tmdbId = tmdbId, season = season,
mediaType = mediaType, episode = episode,
season = season, scraperId = scraper.id,
episode = episode, scraperSettings = settings
scraperId = scraper.id, )
scraperSettings = settings
)
}
}
if (results == null) {
Log.w(TAG, "Scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms")
return emptyList()
}
Log.d(TAG, "Scraper ${scraper.name} returned ${results.size} results") Log.d(TAG, "Scraper ${scraper.name} returned ${results.size} results")
results.map { it.copy(provider = scraper.name) } results.map { it.copy(provider = scraper.name) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to execute scraper ${scraper.name}: ${e.message}", e) Log.e(TAG, "Failed to execute scraper ${scraper.name}: ${e.message}", e)
emptyList() 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>> { suspend fun testScraper(scraperId: String): Result<List<LocalScraperResult>> {
val diagnostics = TestDiagnostics()
val scraper = dataStore.scrapers.first().find { it.id == scraperId } val scraper = dataStore.scrapers.first().find { it.id == scraperId }
if (scraper == null) { ?: return Result.failure(Exception("Scraper not found"))
diagnostics.addStep("Scraper '$scraperId' not found in datastore")
return Result.failure(Exception("Scraper not found"))
}
diagnostics.addStep("Scraper: ${scraper.name} (type=${scraper.type})")
// Use a popular movie for testing (The Matrix - 603) // Use a popular movie for testing (The Matrix - 603)
val testTmdbId = "603" val testTmdbId = "603"
val testMediaType = if (scraper.supportsType("movie")) "movie" else "series" 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 { return try {
val results = when (scraper.type) { val results = executeScraper(scraper, testTmdbId, testMediaType, 1, 1)
RepositoryType.EXTERNAL_DEX -> { Result.success(results)
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)
} catch (e: Exception) { } catch (e: Exception) {
diagnostics.addStep("Exception: ${e.javaClass.simpleName}: ${e.message}") Result.failure(e)
Result.success(emptyList<LocalScraperResult>() to diagnostics)
} }
} }
@ -907,7 +483,7 @@ class PluginManager @Inject constructor(
} }
} }
private suspend fun downloadJsScrapers( private suspend fun downloadScrapers(
repoId: String, repoId: String,
manifestUrl: String, manifestUrl: String,
scraperInfos: List<ScraperManifestInfo> scraperInfos: List<ScraperManifestInfo>
@ -967,13 +543,6 @@ class PluginManager @Inject constructor(
// Create scraper info // Create scraper info
val scraperId = "$repoId:${info.id}" 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( val scraper = ScraperInfo(
id = scraperId, id = scraperId,
repositoryId = repoId, repositoryId = repoId,
@ -982,7 +551,7 @@ class PluginManager @Inject constructor(
version = info.version, version = info.version,
filename = info.filename, filename = info.filename,
supportedTypes = info.supportedTypes, supportedTypes = info.supportedTypes,
enabled = existingScraper?.enabled ?: defaultEnabled, enabled = true,
manifestEnabled = info.enabled, manifestEnabled = info.enabled,
logo = info.logo, logo = info.logo,
contentLanguage = info.contentLanguage ?: emptyList(), contentLanguage = info.contentLanguage ?: emptyList(),
@ -1005,77 +574,4 @@ class PluginManager @Inject constructor(
dataStore.saveScrapers(existingScrapers) 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 package com.nuvio.tv.core.plugin
import android.util.Log import android.util.Log
import com.dokar.quickjs.binding.asyncFunction
import com.dokar.quickjs.binding.define import com.dokar.quickjs.binding.define
import com.dokar.quickjs.binding.function import com.dokar.quickjs.binding.function
import com.dokar.quickjs.quickJs import com.dokar.quickjs.quickJs
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.domain.model.LocalScraperResult import com.nuvio.tv.domain.model.LocalScraperResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -39,10 +39,7 @@ import javax.inject.Singleton
private const val TAG = "PluginRuntime" private const val TAG = "PluginRuntime"
private const val PLUGIN_TIMEOUT_MS = 60_000L private const val PLUGIN_TIMEOUT_MS = 60_000L
private const val MAX_FETCH_RESPONSE_BYTES = 256 * 1024 private const val MAX_FETCH_RESPONSE_BYTES = 2 * 1024 * 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]"
@Singleton @Singleton
class PluginRuntime @Inject constructor() { class PluginRuntime @Inject constructor() {
@ -50,7 +47,6 @@ class PluginRuntime @Inject constructor() {
private val gson: Gson = GsonBuilder().create() private val gson: Gson = GsonBuilder().create()
private val httpClient = OkHttpClient.Builder() private val httpClient = OkHttpClient.Builder()
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
@ -59,6 +55,10 @@ class PluginRuntime @Inject constructor() {
.proxy(java.net.Proxy.NO_PROXY) .proxy(java.net.Proxy.NO_PROXY)
.build() .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 // Pre-compiled regex for :contains() selector conversion
private val containsRegex = Regex(""":contains\(["']([^"']+)["']\)""") private val containsRegex = Regex(""":contains\(["']([^"']+)["']\)""")
@ -144,99 +144,87 @@ class PluginRuntime @Inject constructor() {
scraperId: String, scraperId: String,
scraperSettings: Map<String, Any> scraperSettings: Map<String, Any>
): List<LocalScraperResult> { ): List<LocalScraperResult> {
val documentCache = ConcurrentHashMap<String, Document>() // Clear caches before execution
val elementCache = ConcurrentHashMap<String, Element>() documentCache.clear()
elementCache.clear()
val inFlightCalls = ConcurrentHashMap.newKeySet<Call>() val inFlightCalls = ConcurrentHashMap.newKeySet<Call>()
var resultJson = "[]" var resultJson = "[]"
try { try {
quickJs(Dispatchers.IO) { quickJs(Dispatchers.IO) {
// Define console object - must return null to avoid quickjs conversion issues // Define console object - must return null to avoid quickjs conversion issues
define("console") { define("console") {
function("log") { args -> function("log") { args ->
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
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("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 -> // Define native fetch function (async)
val url = args.getOrNull(0)?.toString() ?: "" asyncFunction("__native_fetch") { args ->
val method = args.getOrNull(1)?.toString() ?: "GET" val url = args.getOrNull(0)?.toString() ?: ""
val headersJson = args.getOrNull(2)?.toString() ?: "{}" val method = args.getOrNull(1)?.toString() ?: "GET"
val body = args.getOrNull(3)?.toString() ?: "" val headersJson = args.getOrNull(2)?.toString() ?: "{}"
try { val body = args.getOrNull(3)?.toString() ?: ""
performNativeFetch(url, method, headersJson, body, inFlightCalls) performNativeFetch(url, method, headersJson, body, inFlightCalls)
} catch (t: Throwable) { }
Log.e(TAG, "Async fetch bridge error for $method $url: ${t.message}")
gson.toJson( // Define URL parser
mapOf( function("__parse_url") { args ->
"ok" to false, val urlString = args.getOrNull(0)?.toString() ?: ""
"status" to 0, parseUrl(urlString)
"statusText" to (t.message ?: "Fetch failed"), }
"url" to url,
"body" to "", // Define cheerio load function
"headers" to emptyMap<String, String>() 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()}"
// Define URL parser elementCache[elId] = el
function("__parse_url") { args -> elId
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) {
"[]"
} }
// Use simple JSON array construction to avoid Gson issues
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
} catch (e: Exception) {
"[]"
} }
}
// Define cheerio find function // Define cheerio find function
function("__cheerio_find") { args -> function("__cheerio_find") { args ->
@ -372,7 +360,7 @@ class PluginRuntime @Inject constructor() {
})(); })();
""".trimIndent() """.trimIndent()
evaluate<Any?>(callCode) evaluate<Any?>(callCode)
} }
return parseJsonResults(resultJson) return parseJsonResults(resultJson)
@ -451,10 +439,10 @@ class PluginRuntime @Inject constructor() {
response.use { httpResponse -> response.use { httpResponse ->
val bodyContentType = httpResponse.body?.contentType() val bodyContentType = httpResponse.body?.contentType()
val contentEncoding = httpResponse.header("Content-Encoding")?.lowercase()?.trim() val contentEncoding = httpResponse.header("Content-Encoding")?.lowercase()?.trim()
val decodedRead = try { val decodedBytes = try {
val stream = httpResponse.body?.byteStream() val stream = httpResponse.body?.byteStream()
if (stream == null) { if (stream == null) {
BoundedReadResult(ByteArray(0), false) ByteArray(0)
} else { } else {
val decodeStream: InputStream = when (contentEncoding) { val decodeStream: InputStream = when (contentEncoding) {
"gzip" -> GZIPInputStream(stream) "gzip" -> GZIPInputStream(stream)
@ -467,14 +455,18 @@ class PluginRuntime @Inject constructor() {
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to read/decode response body for $url: ${e.message}") 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 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>() val responseHeaders = mutableMapOf<String, String>()
httpResponse.headers.forEach { (name, value) -> httpResponse.headers.forEach { (name, value) ->
responseHeaders[name.lowercase()] = truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) responseHeaders[name.lowercase()] = value
} }
val result = mapOf( val result = mapOf(
@ -483,8 +475,7 @@ class PluginRuntime @Inject constructor() {
"statusText" to httpResponse.message, "statusText" to httpResponse.message,
"url" to httpResponse.request.url.toString(), "url" to httpResponse.request.url.toString(),
"body" to responseBody, "body" to responseBody,
"headers" to responseHeaders, "headers" to responseHeaders
"truncated" to decodedRead.truncated
) )
Log.d(TAG, "Fetch result: ${httpResponse.code} ${httpResponse.message} url=$url bodyLen=${responseBody.length} bodyPreview=${responseBody.take(300)}") 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( private fun readAtMostBytes(stream: InputStream, maxBytes: Int): ByteArray {
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 {
val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024))
val buffer = ByteArray(8 * 1024) val buffer = ByteArray(8 * 1024)
var remaining = maxBytes var remaining = maxBytes
var truncated = false
while (remaining > 0) { while (remaining > 0) {
val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) val read = stream.read(buffer, 0, minOf(buffer.size, remaining))
@ -539,10 +508,7 @@ class PluginRuntime @Inject constructor() {
out.write(buffer, 0, read) out.write(buffer, 0, read)
remaining -= read remaining -= read
} }
if (remaining == 0) { return out.toByteArray()
truncated = stream.read() != -1
}
return BoundedReadResult(out.toByteArray(), truncated)
} }
private fun parseUrl(urlString: String): String { private fun parseUrl(urlString: String): String {
@ -576,16 +542,7 @@ class PluginRuntime @Inject constructor() {
globalThis.SCRAPER_ID = "$scraperId"; globalThis.SCRAPER_ID = "$scraperId";
globalThis.SCRAPER_SETTINGS = $settingsJson; globalThis.SCRAPER_SETTINGS = $settingsJson;
if (typeof TMDB_API_KEY === 'undefined') { if (typeof TMDB_API_KEY === 'undefined') {
globalThis.TMDB_API_KEY = "${BuildConfig.TMDB_API_KEY}"; globalThis.TMDB_API_KEY = "1865f43a0549ca50d341dd9ab8b29f49";
}
if (typeof globalThis.global === 'undefined') {
globalThis.global = globalThis;
}
if (typeof globalThis.window === 'undefined') {
globalThis.window = globalThis;
}
if (typeof globalThis.self === 'undefined') {
globalThis.self = globalThis;
} }
// Fetch implementation (async) // 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'; 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); var parsed = JSON.parse(result);
if (signal && signal.aborted) { if (signal && signal.aborted) {
@ -630,15 +587,10 @@ class PluginRuntime @Inject constructor() {
return Promise.resolve(parsed.body); return Promise.resolve(parsed.body);
}, },
json: function() { json: function() {
try { try {
if (parsed.body === null || parsed.body === undefined || parsed.body === '') {
return Promise.resolve(null);
}
return Promise.resolve(JSON.parse(parsed.body)); return Promise.resolve(JSON.parse(parsed.body));
} catch (e) { } catch (e) {
console.error('fetch.json parse error:', e && e.message ? e.message : e); return Promise.reject(new Error('JSON parse error: ' + e.message));
return Promise.resolve(null);
} }
} }
}; };

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

View file

@ -1,6 +1,5 @@
package com.nuvio.tv.core.server package com.nuvio.tv.core.server
import android.content.Context
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD
@ -9,28 +8,12 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
class AddonConfigServer( class AddonConfigServer(
private val context: Context,
private val webConfigMode: WebConfigMode,
private val currentPageStateProvider: () -> PageState, private val currentPageStateProvider: () -> PageState,
private val onChangeProposed: (PendingAddonChange) -> Unit, private val onChangeProposed: (PendingAddonChange) -> Unit,
private val logoProvider: (() -> ByteArray?)? = null, private val logoProvider: (() -> ByteArray?)? = null,
port: Int = 8080 port: Int = 8080
) : NanoHTTPD(port) { ) : 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( data class AddonInfo(
val url: String, val url: String,
val name: String, val name: String,
@ -46,41 +29,9 @@ class AddonConfigServer(
val isDisabled: Boolean 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( data class PageState(
val addons: List<AddonInfo>, val addons: List<AddonInfo>,
val catalogs: List<CatalogInfo>, val catalogs: List<CatalogInfo>
val collections: List<CollectionInfo> = emptyList(),
val disabledCollectionKeys: List<String> = emptyList()
) )
data class PendingAddonChange( data class PendingAddonChange(
@ -88,8 +39,6 @@ class AddonConfigServer(
val proposedUrls: List<String>, val proposedUrls: List<String>,
val proposedCatalogOrderKeys: List<String> = emptyList(), val proposedCatalogOrderKeys: List<String> = emptyList(),
val proposedDisabledCatalogKeys: List<String> = emptyList(), val proposedDisabledCatalogKeys: List<String> = emptyList(),
val proposedCollectionsJson: String? = null,
val proposedDisabledCollectionKeys: List<String> = emptyList(),
var status: ChangeStatus = ChangeStatus.PENDING var status: ChangeStatus = ChangeStatus.PENDING
) )
@ -116,18 +65,13 @@ class AddonConfigServer(
method == Method.GET && uri == "/api/state" -> servePageState() method == Method.GET && uri == "/api/state" -> servePageState()
method == Method.GET && uri == "/api/addons" -> serveAddonList() method == Method.GET && uri == "/api/addons" -> serveAddonList()
method == Method.POST && uri == "/api/addons" -> handleAddonUpdate(session) method == Method.POST && uri == "/api/addons" -> handleAddonUpdate(session)
method == Method.GET && uri == "/api/collections" -> serveCollections()
method == Method.GET && uri.startsWith("/api/status/") -> serveChangeStatus(uri) method == Method.GET && uri.startsWith("/api/status/") -> serveChangeStatus(uri)
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found") else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found")
} }
} }
private fun serveWebPage(): Response { private fun serveWebPage(): Response {
return newFixedLengthResponse( return newFixedLengthResponse(Response.Status.OK, "text/html", AddonWebPage.getHtml())
Response.Status.OK,
"text/html; charset=utf-8",
AddonWebPage.getHtml(context, webConfigMode)
)
} }
private fun serveLogo(): Response { 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 { private fun serveAddonList(): Response {
val addons = currentPageStateProvider().addons val addons = currentPageStateProvider().addons
val json = gson.toJson(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 { private fun servePageState(): Response {
val state = currentPageStateProvider() val state = currentPageStateProvider()
val json = gson.toJson(state) 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 { private fun handleAddonUpdate(session: IHTTPSession): Response {
@ -178,25 +116,16 @@ class AddonConfigServer(
val urls = parseStringList(parsed["urls"]) val urls = parseStringList(parsed["urls"])
val catalogOrderKeys = parseStringList(parsed["catalogOrderKeys"]) val catalogOrderKeys = parseStringList(parsed["catalogOrderKeys"])
val disabledCatalogKeys = parseStringList(parsed["disabledCatalogKeys"]) val disabledCatalogKeys = parseStringList(parsed["disabledCatalogKeys"])
val collectionsRaw = parsed["collections"] PendingAddonChange(
val collectionsJson = if (collectionsRaw != null) gson.toJson(collectionsRaw) else null proposedUrls = urls,
val disabledCollectionKeys = parseStringList(parsed["disabledCollectionKeys"]) proposedCatalogOrderKeys = catalogOrderKeys,
sanitizePendingAddonChange( proposedDisabledCatalogKeys = disabledCatalogKeys
mode = webConfigMode,
proposedChange = PendingAddonChange(
proposedUrls = urls,
proposedCatalogOrderKeys = catalogOrderKeys,
proposedDisabledCatalogKeys = disabledCatalogKeys,
proposedCollectionsJson = collectionsJson,
proposedDisabledCollectionKeys = disabledCollectionKeys
),
currentState = currentPageStateProvider()
) )
} catch (e: Exception) { } catch (e: Exception) {
val error = mapOf("error" to "Invalid request body") val error = mapOf("error" to "Invalid request body")
return newFixedLengthResponse( return newFixedLengthResponse(
Response.Status.BAD_REQUEST, Response.Status.BAD_REQUEST,
"application/json; charset=utf-8", "application/json",
gson.toJson(error) gson.toJson(error)
) )
} }
@ -205,7 +134,7 @@ class AddonConfigServer(
onChangeProposed(change) onChangeProposed(change)
val response = mapOf("status" to "pending_confirmation", "id" to change.id) 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 { private fun serveChangeStatus(uri: String): Response {
@ -213,7 +142,7 @@ class AddonConfigServer(
val change = pendingChanges[id] val change = pendingChanges[id]
val status = change?.status?.name?.lowercase() ?: "not_found" val status = change?.status?.name?.lowercase() ?: "not_found"
val response = mapOf("status" to status) 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> { private fun parseStringList(rawValue: Any?): List<String> {
@ -227,8 +156,6 @@ class AddonConfigServer(
companion object { companion object {
fun startOnAvailablePort( fun startOnAvailablePort(
context: Context,
webConfigMode: WebConfigMode = WebConfigMode.FULL,
currentPageStateProvider: () -> PageState, currentPageStateProvider: () -> PageState,
onChangeProposed: (PendingAddonChange) -> Unit, onChangeProposed: (PendingAddonChange) -> Unit,
logoProvider: (() -> ByteArray?)? = null, logoProvider: (() -> ByteArray?)? = null,
@ -237,14 +164,7 @@ class AddonConfigServer(
): AddonConfigServer? { ): AddonConfigServer? {
for (port in startPort until startPort + maxAttempts) { for (port in startPort until startPort + maxAttempts) {
try { try {
val server = AddonConfigServer( val server = AddonConfigServer(currentPageStateProvider, onChangeProposed, logoProvider, port)
context = context,
webConfigMode = webConfigMode,
currentPageStateProvider = currentPageStateProvider,
onChangeProposed = onChangeProposed,
logoProvider = logoProvider,
port = port
)
server.start(SOCKET_READ_TIMEOUT, false) server.start(SOCKET_READ_TIMEOUT, false)
return server return server
} catch (e: Exception) { } 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 package com.nuvio.tv.core.server
import android.content.Context
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD
@ -9,7 +8,6 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
class RepositoryConfigServer( class RepositoryConfigServer(
private val context: Context,
private val currentRepositoriesProvider: () -> List<RepositoryInfo>, private val currentRepositoriesProvider: () -> List<RepositoryInfo>,
private val onChangeProposed: (PendingRepoChange) -> Unit, private val onChangeProposed: (PendingRepoChange) -> Unit,
private val logoProvider: (() -> ByteArray?)? = null, private val logoProvider: (() -> ByteArray?)? = null,
@ -56,7 +54,7 @@ class RepositoryConfigServer(
} }
private fun serveWebPage(): Response { 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 { private fun serveLogo(): Response {
@ -120,7 +118,6 @@ class RepositoryConfigServer(
companion object { companion object {
fun startOnAvailablePort( fun startOnAvailablePort(
context: Context,
currentRepositoriesProvider: () -> List<RepositoryInfo>, currentRepositoriesProvider: () -> List<RepositoryInfo>,
onChangeProposed: (PendingRepoChange) -> Unit, onChangeProposed: (PendingRepoChange) -> Unit,
logoProvider: (() -> ByteArray?)? = null, logoProvider: (() -> ByteArray?)? = null,
@ -129,7 +126,7 @@ class RepositoryConfigServer(
): RepositoryConfigServer? { ): RepositoryConfigServer? {
for (port in startPort until startPort + maxAttempts) { for (port in startPort until startPort + maxAttempts) {
try { try {
val server = RepositoryConfigServer(context, currentRepositoriesProvider, onChangeProposed, logoProvider, port) val server = RepositoryConfigServer(currentRepositoriesProvider, onChangeProposed, logoProvider, port)
server.start(SOCKET_READ_TIMEOUT, false) server.start(SOCKET_READ_TIMEOUT, false)
return server return server
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -1,27 +1,14 @@
package com.nuvio.tv.core.server 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 { object RepositoryWebPage {
fun getHtml(baseContext: Context): String { fun getHtml(): 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 """
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style> <style>
* { * {
@ -318,30 +305,30 @@ object RepositoryWebPage {
<div class="page"> <div class="page">
<div class="header"> <div class="header">
<img src="/logo.png" alt="NuvioTV" class="header-logo"> <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>
<div class="add-section"> <div class="add-section">
<label>${context.getString(R.string.web_add_repo_url)}</label> <label>Add repository by URL</label>
<div class="add-row"> <div class="add-row">
<input type="url" id="repoUrl" placeholder="${context.getString(R.string.web_placeholder_url)}" autocomplete="off" autocapitalize="off" spellcheck="false"> <input type="url" id="repoUrl" placeholder="https://example.com/manifest.json" autocomplete="off" autocapitalize="off" spellcheck="false">
<button class="btn" id="addBtn" onclick="addRepo()">${context.getString(R.string.web_btn_add)}</button> <button class="btn" id="addBtn" onclick="addRepo()">Add</button>
</div> </div>
<div class="add-error" id="addError"></div> <div class="add-error" id="addError"></div>
</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> <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>
<div class="status-overlay" id="statusOverlay"> <div class="status-overlay" id="statusOverlay">
<div class="status-content" id="statusContent"></div> <div class="status-content" id="statusContent"></div>
</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> <script>
var repos = []; var repos = [];
@ -398,13 +385,13 @@ function renderList() {
li.innerHTML = li.innerHTML =
'<div class="repo-info">' + '<div class="repo-info">' +
'<div class="repo-name">' + escapeHtml(repo.name || repo.url) + '<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>' + '</div>' +
(repo.description ? '<div class="repo-desc">' + escapeHtml(repo.description) + '</div>' : '') + (repo.description ? '<div class="repo-desc">' + escapeHtml(repo.description) + '</div>' : '') +
'<div class="repo-url">' + escapeHtml(repo.url) + '</div>' + '<div class="repo-url">' + escapeHtml(repo.url) + '</div>' +
'</div>' + '</div>' +
'<div class="repo-actions">' + '<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>'; '</div>';
list.appendChild(li); list.appendChild(li);
@ -426,7 +413,7 @@ async function addRepo() {
url = url.replace(/\/+$/, ''); url = url.replace(/\/+$/, '');
if (repos.some(function(r) { return r.url === url; })) { 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'; errorEl.style.display = 'block';
setTimeout(function() { errorEl.style.display = 'none'; }, 3000); setTimeout(function() { errorEl.style.display = 'none'; }, 3000);
return; return;
@ -464,7 +451,7 @@ async function saveChanges() {
saveBtn.disabled = false; saveBtn.disabled = false;
} }
} catch (e) { } 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; saveBtn.disabled = false;
} }
} }
@ -474,8 +461,8 @@ function showPendingStatus() {
var content = document.getElementById('statusContent'); var content = document.getElementById('statusContent');
content.innerHTML = content.innerHTML =
'<div class="status-icon"><div class="spinner"></div></div>' + '<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-title">Waiting for TV</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_waiting_tv).replace("'", "\\'")}</div>'; '<div class="status-message">Please confirm the changes on your TV to apply them.</div>';
content.className = 'status-content'; content.className = 'status-content';
overlay.classList.add('visible'); overlay.classList.add('visible');
} }
@ -484,8 +471,8 @@ function showSuccessStatus() {
var content = document.getElementById('statusContent'); var content = document.getElementById('statusContent');
content.innerHTML = 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-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-title">Changes Applied</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_repo_updated).replace("'", "\\'")}</div>'; '<div class="status-message">Your repository configuration has been updated on the TV.</div>';
content.className = 'status-content status-success'; content.className = 'status-content status-success';
setTimeout(dismissStatus, 2500); setTimeout(dismissStatus, 2500);
} }
@ -494,8 +481,8 @@ function showRejectedStatus() {
var content = document.getElementById('statusContent'); var content = document.getElementById('statusContent');
content.innerHTML = 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-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-title">Changes Rejected</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_changes_rejected).replace("'", "\\'")}</div>'; '<div class="status-message">The changes were declined on the TV. Your list has been reverted.</div>';
content.className = 'status-content status-rejected'; content.className = 'status-content status-rejected';
setTimeout(function() { setTimeout(function() {
repos = JSON.parse(JSON.stringify(originalRepos)); repos = JSON.parse(JSON.stringify(originalRepos));
@ -509,9 +496,9 @@ function showErrorStatus(msg) {
var content = document.getElementById('statusContent'); var content = document.getElementById('statusContent');
content.innerHTML = 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-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-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'; content.className = 'status-content status-error';
overlay.classList.add('visible'); overlay.classList.add('visible');
} }
@ -520,9 +507,9 @@ function showTimeoutStatus() {
var content = document.getElementById('statusContent'); var content = document.getElementById('statusContent');
content.innerHTML = 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-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-title">Timed Out</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_timeout).replace("'", "\\'")}</div>' + '<div class="status-message">No response from the TV. Please try again.</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'; content.className = 'status-content status-error';
} }
@ -530,9 +517,9 @@ function showDisconnectedStatus() {
var content = document.getElementById('statusContent'); var content = document.getElementById('statusContent');
content.innerHTML = 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-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-title">Connection Lost</div>' +
'<div class="status-message">${context.getString(R.string.web_status_msg_connection_lost).replace("'", "\\'")}</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()">${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'; content.className = 'status-content status-error';
} }
@ -604,5 +591,4 @@ loadRepos();
</body> </body>
</html> </html>
""".trimIndent() """.trimIndent()
}
} }

View file

@ -50,7 +50,6 @@ class AddonSyncService @Inject constructor(
} }
val localUrls = addonPreferences.installedAddonUrls.first() val localUrls = addonPreferences.installedAddonUrls.first()
val userSetNames = addonPreferences.userSetNames.first()
Log.d(TAG, "pushToRemote: localUrls count=${localUrls.size} for profile $profileId") Log.d(TAG, "pushToRemote: localUrls count=${localUrls.size} for profile $profileId")
val params = buildJsonObject { val params = buildJsonObject {
@ -59,10 +58,6 @@ class AddonSyncService @Inject constructor(
addJsonObject { addJsonObject {
put("url", url) put("url", url)
put("sort_order", index) put("sort_order", index)
val name = userSetNames[url]
if (!name.isNullOrBlank()) {
put("name", name)
}
} }
} }
}) })
@ -101,14 +96,6 @@ class AddonSyncService @Inject constructor(
.decodeList<SupabaseAddon>() .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( Result.success(
remoteAddons remoteAddons
.sortedBy { it.sortOrder } .sortedBy { it.sortOrder }
@ -119,17 +106,4 @@ class AddonSyncService @Inject constructor(
Result.failure(e) 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 TAG = "LibrarySyncService"
private const val PULL_PAGE_SIZE = 500
@Singleton @Singleton
class LibrarySyncService @Inject constructor( class LibrarySyncService @Inject constructor(
private val authManager: AuthManager, private val authManager: AuthManager,
@ -68,7 +66,6 @@ class LibrarySyncService @Inject constructor(
item.genres.forEach { genre -> add(kotlinx.serialization.json.JsonPrimitive(genre)) } item.genres.forEach { genre -> add(kotlinx.serialization.json.JsonPrimitive(genre)) }
}) })
put("addon_base_url", item.addonBaseUrl) 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 profileId = profileManager.activeProfileId.value
val allItems = mutableListOf<SupabaseLibraryItem>() val params = buildJsonObject {
var offset = 0 put("p_profile_id", profileId)
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 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( SavedLibraryItem(
id = entry.contentId, id = entry.contentId,
type = entry.contentType, type = entry.contentType,
@ -128,8 +113,7 @@ class LibrarySyncService @Inject constructor(
releaseInfo = entry.releaseInfo, releaseInfo = entry.releaseInfo,
imdbRating = entry.imdbRating, imdbRating = entry.imdbRating,
genres = entry.genres, genres = entry.genres,
addonBaseUrl = entry.addonBaseUrl, addonBaseUrl = entry.addonBaseUrl
addedAt = entry.addedAt
) )
}) })
} catch (e: Exception) { } 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.core.profile.ProfileManager
import com.nuvio.tv.data.local.PluginDataStore import com.nuvio.tv.data.local.PluginDataStore
import com.nuvio.tv.data.remote.supabase.SupabasePlugin import com.nuvio.tv.data.remote.supabase.SupabasePlugin
import com.nuvio.tv.domain.model.RemotePluginInfo
import io.github.jan.supabase.postgrest.Postgrest import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -61,7 +60,6 @@ class PluginSyncService @Inject constructor(
put("name", repo.name) put("name", repo.name)
put("enabled", repo.enabled) put("enabled", repo.enabled)
put("sort_order", index) 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 { try {
val effectiveUserId = authManager.getEffectiveUserId(fallbackToOwnIdOnFailure = false) val effectiveUserId = authManager.getEffectiveUserId(fallbackToOwnIdOnFailure = false)
?: return@withContext Result.failure( ?: return@withContext Result.failure(
@ -103,7 +101,7 @@ class PluginSyncService @Inject constructor(
Result.success( Result.success(
remotePlugins remotePlugins
.sortedBy { it.sortOrder } .sortedBy { it.sortOrder }
.map { RemotePluginInfo(url = it.url, repoType = it.repoType) } .map { it.url }
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to get remote repo URLs", e) 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.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.ProfileDataStore 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.SupabaseProfile
import com.nuvio.tv.data.remote.supabase.SupabaseProfilePinVerifyResult
import com.nuvio.tv.domain.model.UserProfile import com.nuvio.tv.domain.model.UserProfile
import io.github.jan.supabase.postgrest.Postgrest import io.github.jan.supabase.postgrest.Postgrest
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -49,7 +47,6 @@ class ProfileSyncService @Inject constructor(
put("avatar_color_hex", profile.avatarColorHex) put("avatar_color_hex", profile.avatarColorHex)
put("uses_primary_addons", profile.usesPrimaryAddons) put("uses_primary_addons", profile.usesPrimaryAddons)
put("uses_primary_plugins", profile.usesPrimaryPlugins) put("uses_primary_plugins", profile.usesPrimaryPlugins)
put("avatar_id", profile.avatarId)
} }
} }
}) })
@ -81,8 +78,7 @@ class ProfileSyncService @Inject constructor(
name = entry.name, name = entry.name,
avatarColorHex = entry.avatarColorHex, avatarColorHex = entry.avatarColorHex,
usesPrimaryAddons = entry.usesPrimaryAddons, usesPrimaryAddons = entry.usesPrimaryAddons,
usesPrimaryPlugins = entry.usesPrimaryPlugins, usesPrimaryPlugins = entry.usesPrimaryPlugins
avatarId = entry.avatarId
) )
} }
@ -114,73 +110,4 @@ class ProfileSyncService @Inject constructor(
Result.failure(e) 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.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -31,12 +29,9 @@ class StartupSyncService @Inject constructor(
private val authManager: AuthManager, private val authManager: AuthManager,
private val pluginSyncService: PluginSyncService, private val pluginSyncService: PluginSyncService,
private val addonSyncService: AddonSyncService, private val addonSyncService: AddonSyncService,
private val collectionSyncService: CollectionSyncService,
private val homeCatalogSettingsSyncService: HomeCatalogSettingsSyncService,
private val watchProgressSyncService: WatchProgressSyncService, private val watchProgressSyncService: WatchProgressSyncService,
private val librarySyncService: LibrarySyncService, private val librarySyncService: LibrarySyncService,
private val watchedItemsSyncService: WatchedItemsSyncService, private val watchedItemsSyncService: WatchedItemsSyncService,
private val profileSettingsSyncService: ProfileSettingsSyncService,
private val profileSyncService: ProfileSyncService, private val profileSyncService: ProfileSyncService,
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
private val addonRepository: AddonRepositoryImpl, private val addonRepository: AddonRepositoryImpl,
@ -51,15 +46,10 @@ class StartupSyncService @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var startupPullJob: Job? = null private var startupPullJob: Job? = null
private var lastPulledKey: String? = null private var lastPulledKey: String? = null
private var lastPulledIncludedProfileSettings: Boolean = false
@Volatile @Volatile
private var forceSyncRequested: Boolean = false private var forceSyncRequested: Boolean = false
@Volatile @Volatile
private var forceSyncIncludesProfileSettings: Boolean = true
@Volatile
private var pendingResyncKey: String? = null private var pendingResyncKey: String? = null
@Volatile
private var pendingResyncIncludesProfileSettings: Boolean = false
init { init {
scope.launch { scope.launch {
@ -67,23 +57,15 @@ class StartupSyncService @Inject constructor(
when (state) { when (state) {
is AuthState.FullAccount -> { is AuthState.FullAccount -> {
val force = forceSyncRequested val force = forceSyncRequested
val includeProfileSettings = if (force) forceSyncIncludesProfileSettings else true val started = scheduleStartupPull(state.userId, force = force)
val started = scheduleStartupPull(
userId = state.userId,
force = force,
includeProfileSettings = includeProfileSettings
)
if (force && started) forceSyncRequested = false if (force && started) forceSyncRequested = false
} }
is AuthState.SignedOut -> { is AuthState.SignedOut -> {
startupPullJob?.cancel() startupPullJob?.cancel()
startupPullJob = null startupPullJob = null
lastPulledKey = null lastPulledKey = null
lastPulledIncludedProfileSettings = false
forceSyncRequested = false forceSyncRequested = false
forceSyncIncludesProfileSettings = true
pendingResyncKey = null pendingResyncKey = null
pendingResyncIncludesProfileSettings = false
} }
is AuthState.Loading -> Unit is AuthState.Loading -> Unit
} }
@ -91,16 +73,11 @@ class StartupSyncService @Inject constructor(
} }
} }
fun requestSyncNow(includeProfileSettings: Boolean = true) { fun requestSyncNow() {
forceSyncRequested = true forceSyncRequested = true
forceSyncIncludesProfileSettings = forceSyncIncludesProfileSettings || includeProfileSettings
when (val state = authManager.authState.value) { when (val state = authManager.authState.value) {
is AuthState.FullAccount -> { is AuthState.FullAccount -> {
val started = scheduleStartupPull( val started = scheduleStartupPull(state.userId, force = true)
userId = state.userId,
force = true,
includeProfileSettings = includeProfileSettings
)
if (started) forceSyncRequested = false if (started) forceSyncRequested = false
} }
else -> Unit else -> Unit
@ -112,40 +89,28 @@ class StartupSyncService @Inject constructor(
return "${userId}_p${profileId}" return "${userId}_p${profileId}"
} }
private fun scheduleStartupPull( private fun scheduleStartupPull(userId: String, force: Boolean = false): Boolean {
userId: String,
force: Boolean = false,
includeProfileSettings: Boolean = true
): Boolean {
val key = pullKey(userId) val key = pullKey(userId)
if ( if (!force && lastPulledKey == key) return false
!force &&
lastPulledKey == key &&
(!includeProfileSettings || lastPulledIncludedProfileSettings)
) {
return false
}
// Never cancel an active sync — it may be mid-write to DataStore. // Never cancel an active sync — it may be mid-write to DataStore.
// Instead, schedule a follow-up sync after the current one finishes. // Instead, schedule a follow-up sync after the current one finishes.
if (startupPullJob?.isActive == true) { if (startupPullJob?.isActive == true) {
if (force) { if (force) pendingResyncKey = key
pendingResyncKey = key
pendingResyncIncludesProfileSettings =
pendingResyncIncludesProfileSettings || includeProfileSettings
}
return false return false
} }
startupPullJob = scope.launch { startupPullJob = scope.launch {
val maxAttempts = 3 val maxAttempts = 3
var syncCompleted = false var syncCompleted = false
for (attempt in 1..maxAttempts) { repeat(maxAttempts) { index ->
val result = pullRemoteData(includeProfileSettings = includeProfileSettings) val attempt = index + 1
Log.d(TAG, "Startup sync attempt $attempt/$maxAttempts for key=$key")
val result = pullRemoteData()
if (result.isSuccess) { if (result.isSuccess) {
lastPulledKey = key lastPulledKey = key
lastPulledIncludedProfileSettings = includeProfileSettings Log.d(TAG, "Startup sync completed for key=$key")
syncCompleted = true syncCompleted = true
break return@repeat
} }
Log.w(TAG, "Startup sync attempt $attempt failed for key=$key", result.exceptionOrNull()) Log.w(TAG, "Startup sync attempt $attempt failed for key=$key", result.exceptionOrNull())
@ -156,26 +121,17 @@ class StartupSyncService @Inject constructor(
val resyncKey = pendingResyncKey val resyncKey = pendingResyncKey
if (resyncKey != null) { if (resyncKey != null) {
val resyncIncludesProfileSettings = pendingResyncIncludesProfileSettings
pendingResyncKey = null pendingResyncKey = null
pendingResyncIncludesProfileSettings = false if (!syncCompleted || resyncKey != lastPulledKey) {
if ( Log.d(TAG, "Running pending re-sync for key=$resyncKey")
!syncCompleted || scheduleStartupPull(userId, force = true)
resyncKey != lastPulledKey ||
(resyncIncludesProfileSettings && !lastPulledIncludedProfileSettings)
) {
scheduleStartupPull(
userId = userId,
force = true,
includeProfileSettings = resyncIncludesProfileSettings
)
} }
} }
} }
return true return true
} }
private suspend fun pullRemoteData(includeProfileSettings: Boolean): Result<Unit> { private suspend fun pullRemoteData(): Result<Unit> {
try { try {
val profileId = profileManager.activeProfileId.value val profileId = profileManager.activeProfileId.value
Log.d(TAG, "Pulling remote data for profile $profileId") Log.d(TAG, "Pulling remote data for profile $profileId")
@ -184,94 +140,37 @@ class StartupSyncService @Inject constructor(
profileSyncService.pullFromRemote().getOrElse { throw it } profileSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled profiles from remote") Log.d(TAG, "Pulled profiles from remote")
if (includeProfileSettings) { pluginManager.isSyncingFromRemote = true
// Pull profile-scoped UI/player/settings blob. try {
// If not present, local settings are preserved. val remotePluginUrls = pluginSyncService.getRemoteRepoUrls().getOrElse { throw it }
profileSettingsSyncService.pullCurrentProfileFromRemote() pluginManager.reconcileWithRemoteRepoUrls(
.onSuccess { applied -> remoteUrls = remotePluginUrls,
Log.d(TAG, "Profile settings blob pull completed for profile $profileId (applied=$applied)") removeMissingLocal = true
} )
.onFailure { e -> Log.d(TAG, "Pulled ${remotePluginUrls.size} plugin repos from remote for profile $profileId")
Log.e(TAG, "Failed to pull profile settings blob, keeping local settings", e) } 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. addonRepository.isSyncingFromRemote = true
// Plugins, addons, collections, and home catalog settings don't depend on each other. try {
coroutineScope { val remoteAddonUrls = addonSyncService.getRemoteAddonUrls().getOrElse { throw it }
val pluginJob = async { addonRepository.reconcileWithRemoteAddonUrls(
pluginManager.isSyncingFromRemote = true remoteUrls = remoteAddonUrls,
try { removeMissingLocal = true
val remotePlugins = pluginSyncService.getRemoteRepoUrls().getOrElse { throw it } )
pluginManager.reconcileWithRemoteRepoUrls( Log.d(TAG, "Pulled ${remoteAddonUrls.size} addons from remote for profile $profileId")
remotePlugins = remotePlugins, } catch (e: Exception) {
removeMissingLocal = true Log.e(TAG, "Failed to pull addons from remote, keeping local cache", e)
) } finally {
Log.d(TAG, "Pulled ${remotePlugins.size} plugin repos from remote for profile $profileId") addonRepository.isSyncingFromRemote = false
} 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()
} }
val isTraktConnected = traktAuthDataStore.isEffectivelyAuthenticated.first() val isPrimaryProfile = profileManager.activeProfileId.value == 1
val shouldUseSupabaseWatchProgressSync = watchProgressSyncService.shouldUseSupabaseWatchProgressSync() val isTraktConnected = isPrimaryProfile && traktAuthDataStore.isAuthenticated.first()
Log.d( Log.d(TAG, "Watch progress sync: isTraktConnected=$isTraktConnected isPrimaryProfile=$isPrimaryProfile")
TAG,
"Watch progress sync: isTraktConnected=$isTraktConnected shouldUseSupabaseWatchProgressSync=$shouldUseSupabaseWatchProgressSync"
)
if (!isTraktConnected) { if (!isTraktConnected) {
// Pull library and watched items first — these are lightweight and critical. // 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; // Watch progress is pulled last because the table is large and may time out;
@ -312,29 +211,6 @@ class StartupSyncService @Inject constructor(
} finally { } finally {
watchProgressRepository.isSyncingFromRemote = false 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 { } else {
Log.d(TAG, "Skipping watch progress & library sync (Trakt connected)") 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.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.TraktAuthDataStore 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.local.WatchProgressPreferences
import com.nuvio.tv.data.remote.supabase.SupabaseWatchProgress import com.nuvio.tv.data.remote.supabase.SupabaseWatchProgress
import com.nuvio.tv.domain.model.WatchProgress import com.nuvio.tv.domain.model.WatchProgress
@ -29,7 +27,6 @@ class WatchProgressSyncService @Inject constructor(
private val postgrest: Postgrest, private val postgrest: Postgrest,
private val watchProgressPreferences: WatchProgressPreferences, private val watchProgressPreferences: WatchProgressPreferences,
private val traktAuthDataStore: TraktAuthDataStore, private val traktAuthDataStore: TraktAuthDataStore,
private val traktSettingsDataStore: TraktSettingsDataStore,
private val profileManager: ProfileManager private val profileManager: ProfileManager
) { ) {
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T { 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) { suspend fun deleteFromRemote(keys: Collection<String>): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
if (!shouldUseSupabaseWatchProgressSync()) { if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress delete")
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
} }
@ -85,15 +77,13 @@ class WatchProgressSyncService @Inject constructor(
*/ */
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) { suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
if (!shouldUseSupabaseWatchProgressSync()) { if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Using Trakt watch progress, skipping watch progress push") Log.d(TAG, "Trakt connected, skipping watch progress push")
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
} }
val rawEntries = watchProgressPreferences.getAllRawEntries() val rawEntries = watchProgressPreferences.getAllRawEntries()
val entries = canonicalizeForRemote(rawEntries).filterValues { progress -> val entries = canonicalizeForRemote(rawEntries)
!(progress.position <= 1L && progress.duration <= 1L && progress.duration > 0L)
}
Log.d(TAG, "pushToRemote: ${rawEntries.size} local entries, ${entries.size} canonical entries to push") Log.d(TAG, "pushToRemote: ${rawEntries.size} local entries, ${entries.size} canonical entries to push")
entries.forEach { (key, progress) -> 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}") 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) { suspend fun pushSingleToRemote(key: String, progress: WatchProgress): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
if (!shouldUseSupabaseWatchProgressSync()) { if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Using Trakt watch progress, skipping single watch progress push") Log.d(TAG, "Trakt connected, skipping single watch progress push")
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
} }
@ -176,8 +166,8 @@ class WatchProgressSyncService @Inject constructor(
*/ */
suspend fun pullFromRemote(): Result<List<Pair<String, WatchProgress>>> = withContext(Dispatchers.IO) { suspend fun pullFromRemote(): Result<List<Pair<String, WatchProgress>>> = withContext(Dispatchers.IO) {
try { try {
if (!shouldUseSupabaseWatchProgressSync()) { if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Using Trakt watch progress, skipping watch progress pull") Log.d(TAG, "Trakt connected, skipping watch progress pull")
return@withContext Result.success(emptyList()) return@withContext Result.success(emptyList())
} }
@ -286,11 +276,7 @@ class WatchProgressSyncService @Inject constructor(
} }
} }
.groupBy { it.contentId } .groupBy { it.contentId }
.mapValues { (_, episodes) -> episodes.maxWithOrNull( .mapValues { (_, episodes) -> episodes.maxByOrNull { it.lastWatched } }
compareBy<WatchProgress> { it.lastWatched }
.thenBy { it.season ?: 0 }
.thenBy { it.episode ?: 0 }
) }
latestEpisodeByContent.forEach { (contentId, latestEpisode) -> latestEpisodeByContent.forEach { (contentId, latestEpisode) ->
val latest = latestEpisode ?: return@forEach 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.auth.AuthManager
import com.nuvio.tv.core.profile.ProfileManager import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.local.TraktAuthDataStore 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.local.WatchedItemsPreferences
import com.nuvio.tv.data.remote.supabase.SupabaseWatchedItem import com.nuvio.tv.data.remote.supabase.SupabaseWatchedItem
import com.nuvio.tv.domain.model.WatchedItem import com.nuvio.tv.domain.model.WatchedItem
@ -22,7 +20,6 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "WatchedItemsSyncService" private const val TAG = "WatchedItemsSyncService"
private const val WATCHED_ITEMS_PAGE_SIZE = 900
@Singleton @Singleton
class WatchedItemsSyncService @Inject constructor( class WatchedItemsSyncService @Inject constructor(
@ -30,7 +27,6 @@ class WatchedItemsSyncService @Inject constructor(
private val postgrest: Postgrest, private val postgrest: Postgrest,
private val watchedItemsPreferences: WatchedItemsPreferences, private val watchedItemsPreferences: WatchedItemsPreferences,
private val traktAuthDataStore: TraktAuthDataStore, private val traktAuthDataStore: TraktAuthDataStore,
private val traktSettingsDataStore: TraktSettingsDataStore,
private val profileManager: ProfileManager private val profileManager: ProfileManager
) { ) {
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T { 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) { suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
if (!shouldUseSupabaseWatchProgressSync()) { if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Using Trakt watch progress, skipping watched items push") Log.d(TAG, "Trakt connected, skipping watched items push")
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
} }
@ -90,116 +80,35 @@ class WatchedItemsSyncService @Inject constructor(
suspend fun pullFromRemote(): Result<List<WatchedItem>> = withContext(Dispatchers.IO) { suspend fun pullFromRemote(): Result<List<WatchedItem>> = withContext(Dispatchers.IO) {
try { try {
if (!shouldUseSupabaseWatchProgressSync()) { if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Using Trakt watch progress, skipping watched items pull") Log.d(TAG, "Trakt connected, skipping watched items pull")
return@withContext Result.success(emptyList()) return@withContext Result.success(emptyList())
} }
val profileId = profileManager.activeProfileId.value val profileId = profileManager.activeProfileId.value
val allItems = mutableListOf<WatchedItem>() val params = buildJsonObject {
var page = 1 put("p_profile_id", profileId)
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 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") Log.d(TAG, "pullFromRemote: fetched ${remote.size} watched items from Supabase for profile $profileId")
Result.success(allItems)
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) { } catch (e: Exception) {
Log.e(TAG, "Failed to pull watched items from remote", e) Log.e(TAG, "Failed to pull watched items from remote", e)
Result.failure(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,50 +1,42 @@
package com.nuvio.tv.core.tmdb package com.nuvio.tv.core.tmdb
import android.util.Log import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.TmdbApi 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.TmdbEpisode
import com.nuvio.tv.data.remote.api.TmdbImage import com.nuvio.tv.data.remote.api.TmdbImage
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCrew import com.nuvio.tv.data.remote.api.TmdbPersonCreditCrew
import com.nuvio.tv.data.remote.api.TmdbRecommendationResult import com.nuvio.tv.data.remote.api.TmdbRecommendationResult
import com.nuvio.tv.core.device.DeviceCapabilities
import com.nuvio.tv.domain.model.ContentType import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.MetaCastMember import com.nuvio.tv.domain.model.MetaCastMember
import com.nuvio.tv.domain.model.MetaCompany import com.nuvio.tv.domain.model.MetaCompany
import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.domain.model.MetaPreview
import com.nuvio.tv.domain.model.PersonDetail import com.nuvio.tv.domain.model.PersonDetail
import com.nuvio.tv.domain.model.PosterShape import com.nuvio.tv.domain.model.PosterShape
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "TmdbMetadataService" private const val TAG = "TmdbMetadataService"
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
@Singleton @Singleton
class TmdbMetadataService @Inject constructor( class TmdbMetadataService @Inject constructor(
private val tmdbApi: TmdbApi private val tmdbApi: TmdbApi,
private val deviceCapabilities: DeviceCapabilities
) { ) {
// In-memory caches // In-memory caches
private val enrichmentCache = ConcurrentHashMap<String, TmdbEnrichment>() private val enrichmentCache = ConcurrentHashMap<String, TmdbEnrichment>()
private val episodeCache = ConcurrentHashMap<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>() 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 personCache = ConcurrentHashMap<String, PersonDetail>()
private val moreLikeThisCache = ConcurrentHashMap<String, List<MetaPreview>>() 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( suspend fun fetchEnrichment(
tmdbId: String, tmdbId: String,
@ -55,13 +47,8 @@ class TmdbMetadataService @Inject constructor(
val normalizedLanguage = normalizeTmdbLanguage(language) val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$tmdbId:${contentType.name}:$normalizedLanguage" val cacheKey = "$tmdbId:${contentType.name}:$normalizedLanguage"
enrichmentCache[cacheKey]?.let { return@withContext it } enrichmentCache[cacheKey]?.let { return@withContext it }
enrichmentInFlight[cacheKey]?.let { return@withContext it.await() }
val numericId = tmdbId.toIntOrNull() ?: return@withContext null 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) { val tmdbType = when (contentType) {
ContentType.SERIES, ContentType.TV -> "tv" ContentType.SERIES, ContentType.TV -> "tv"
else -> "movie" else -> "movie"
@ -121,11 +108,10 @@ class TmdbMetadataService @Inject constructor(
val description = details?.overview?.takeIf { it.isNotBlank() } val description = details?.overview?.takeIf { it.isNotBlank() }
val releaseInfo = details?.releaseDate val releaseInfo = details?.releaseDate
?: details?.firstAirDate ?: details?.firstAirDate
val status = details?.status?.trim()?.takeIf { it.isNotBlank() }
val rating = details?.voteAverage val rating = details?.voteAverage
val runtime = details?.runtime ?: details?.episodeRunTime?.firstOrNull() val runtime = details?.runtime ?: details?.episodeRunTime?.firstOrNull()
val countries = details?.productionCountries val countries = details?.productionCountries
?.mapNotNull { it.iso31661?.trim()?.uppercase()?.takeIf { code -> code.isNotBlank() } } ?.mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } }
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
?: details?.originCountry?.takeIf { it.isNotEmpty() } ?: details?.originCountry?.takeIf { it.isNotEmpty() }
val language = details?.originalLanguage?.takeIf { it.isNotBlank() } val language = details?.originalLanguage?.takeIf { it.isNotBlank() }
@ -136,8 +122,7 @@ class TmdbMetadataService @Inject constructor(
val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany( MetaCompany(
name = name, name = name,
logo = buildImageUrl(company.logoPath, size = "w300"), logo = buildLogoUrl(company.logoPath)
tmdbId = company.id
) )
} }
val networks = details?.networks val networks = details?.networks
@ -146,21 +131,24 @@ class TmdbMetadataService @Inject constructor(
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany( MetaCompany(
name = name, name = name,
logo = buildImageUrl(network.logoPath, size = "w300"), logo = buildLogoUrl(network.logoPath)
tmdbId = network.id
) )
} }
val poster = buildImageUrl(details?.posterPath, size = "w500") val poster = buildPosterUrl(details?.posterPath)
val backdrop = buildImageUrl(details?.backdropPath, size = "w1280") val backdrop = buildBackdropUrl(details?.backdropPath)
val collectionId = details?.belongsToCollection?.id
val collectionName = details?.belongsToCollection?.name
val logoPath = images?.logos?.let { val logoPath = images?.logos
selectBestLocalizedImagePath(it, normalizedLanguage) ?.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") val logo = buildLogoUrl(logoPath)
val castMembers = credits?.cast val castMembers = credits?.cast
.orEmpty() .orEmpty()
@ -169,7 +157,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember( MetaCastMember(
name = name, name = name,
character = member.character?.takeIf { it.isNotBlank() }, character = member.character?.takeIf { it.isNotBlank() },
photo = buildImageUrl(member.profilePath, size = "w500"), photo = buildProfileUrl(member.profilePath),
tmdbId = member.id tmdbId = member.id
) )
} }
@ -183,7 +171,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember( MetaCastMember(
name = name, name = name,
character = "Creator", character = "Creator",
photo = buildImageUrl(creator.profilePath, size = "w500"), photo = buildProfileUrl(creator.profilePath),
tmdbId = tmdbPersonId tmdbId = tmdbPersonId
) )
} }
@ -211,7 +199,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember( MetaCastMember(
name = name, name = name,
character = "Director", character = "Director",
photo = buildImageUrl(member.profilePath, size = "w500"), photo = buildProfileUrl(member.profilePath),
tmdbId = tmdbPersonId tmdbId = tmdbPersonId
) )
} }
@ -234,7 +222,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember( MetaCastMember(
name = name, name = name,
character = "Writer", character = "Writer",
photo = buildImageUrl(member.profilePath, size = "w500"), photo = buildProfileUrl(member.profilePath),
tmdbId = tmdbPersonId tmdbId = tmdbPersonId
) )
} }
@ -273,7 +261,7 @@ class TmdbMetadataService @Inject constructor(
genres.isEmpty() && description == null && backdrop == null && logo == null && genres.isEmpty() && description == null && backdrop == null && logo == null &&
poster == null && castMembers.isEmpty() && director.isEmpty() && writer.isEmpty() && poster == null && castMembers.isEmpty() && director.isEmpty() && writer.isEmpty() &&
releaseInfo == null && rating == null && runtime == null && countries.isNullOrEmpty() && language == null && 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 return@withContext null
} }
@ -296,24 +284,14 @@ class TmdbMetadataService @Inject constructor(
productionCompanies = productionCompanies, productionCompanies = productionCompanies,
networks = networks, networks = networks,
ageRating = ageRating, ageRating = ageRating,
status = status,
countries = countries, countries = countries,
language = language, language = language
collectionId = collectionId,
collectionName = collectionName
) )
enrichmentCache[cacheKey] = enrichment enrichmentCache[cacheKey] = enrichment
requestDeferred.complete(enrichment)
enrichment enrichment
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e) Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e)
requestDeferred.complete(null)
null null
} finally {
enrichmentInFlight.remove(cacheKey, requestDeferred)
} }
} }
@ -325,41 +303,27 @@ class TmdbMetadataService @Inject constructor(
val normalizedLanguage = normalizeTmdbLanguage(language) val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$tmdbId:${seasonNumbers.sorted().joinToString(",")}:$normalizedLanguage" val cacheKey = "$tmdbId:${seasonNumbers.sorted().joinToString(",")}:$normalizedLanguage"
episodeCache[cacheKey]?.let { return@withContext it } episodeCache[cacheKey]?.let { return@withContext it }
episodeInFlight[cacheKey]?.let { return@withContext it.await() }
val numericId = tmdbId.toIntOrNull() ?: return@withContext emptyMap() 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>() val result = mutableMapOf<Pair<Int, Int>, TmdbEpisodeEnrichment>()
try { seasonNumbers.distinct().forEach { season ->
seasonNumbers.distinct().forEach { season -> try {
try { val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY, normalizedLanguage)
val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY, normalizedLanguage) val episodes = response.body()?.episodes.orEmpty()
val episodes = response.body()?.episodes.orEmpty() episodes.forEach { ep ->
episodes.forEach { ep -> val epNum = ep.episodeNumber ?: return@forEach
val epNum = ep.episodeNumber ?: return@forEach result[season to epNum] = ep.toEnrichment()
result[season to epNum] = ep.toEnrichment()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
} }
} 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( suspend fun fetchMoreLikeThis(
@ -442,22 +406,9 @@ class TmdbMetadataService @Inject constructor(
) )
} }
val backdrop = buildImageUrl(localizedBackdropPath ?: rec.backdropPath, size = "w1280") val backdrop = buildBackdropUrl(localizedBackdropPath ?: rec.backdropPath)
val fallbackPoster = buildImageUrl(rec.posterPath, size = "w780") val fallbackPoster = buildBackdropUrl(rec.posterPath)
val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4)
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)
}
MetaPreview( MetaPreview(
id = "tmdb:${rec.id}", id = "tmdb:${rec.id}",
@ -484,363 +435,20 @@ class TmdbMetadataService @Inject constructor(
} }
} }
private val collectionCache = ConcurrentHashMap<String, List<MetaPreview>>() private fun buildPosterUrl(path: String?): String? =
buildImageUrl(path, deviceCapabilities.tmdbPosterSize)
suspend fun fetchMovieCollection( private fun buildBackdropUrl(path: String?): String? =
collectionId: Int, buildImageUrl(path, deviceCapabilities.tmdbBackdropSize)
language: String = "en"
): List<MetaPreview> = withContext(Dispatchers.IO) {
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$collectionId:$normalizedLanguage:collection"
collectionCache[cacheKey]?.let { return@withContext it }
try { private fun buildProfileUrl(path: String?): String? =
val collectionResponse = tmdbApi.getCollectionDetails(collectionId, TMDB_API_KEY, normalizedLanguage).body() buildImageUrl(path, deviceCapabilities.tmdbProfileSize)
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 { private fun buildLogoUrl(path: String?): String? =
sortedParts.map { part -> buildImageUrl(path, deviceCapabilities.tmdbLogoSize)
async {
val title = part.title ?: return@async null
val localizedBackdropPath = runCatching { private fun buildStillUrl(path: String?): String? =
tmdbApi.getMovieImages(part.id, TMDB_API_KEY, includeImageLanguage).body() buildImageUrl(path, deviceCapabilities.tmdbStillSize)
}.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? { private fun buildImageUrl(path: String?, size: String): String? {
val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null
@ -848,21 +456,11 @@ class TmdbMetadataService @Inject constructor(
} }
private fun normalizeTmdbLanguage(language: String?): String { private fun normalizeTmdbLanguage(language: String?): String {
val raw = language return language
?.trim() ?.trim()
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.replace('_', '-') ?.replace('_', '-')
?: return "en" ?: "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
}
} }
private fun selectBestLocalizedImagePath( private fun selectBestLocalizedImagePath(
@ -871,12 +469,9 @@ class TmdbMetadataService @Inject constructor(
): String? { ): String? {
if (images.isEmpty()) return null if (images.isEmpty()) return null
val languageCode = normalizedLanguage.substringBefore("-") val languageCode = normalizedLanguage.substringBefore("-")
val regionCode = normalizedLanguage.substringAfter("-", "").uppercase(Locale.US).takeIf { it.length == 2 }
?: DEFAULT_LANGUAGE_REGIONS[languageCode]
return images return images
.sortedWith( .sortedWith(
compareByDescending<TmdbImage> { it.iso6391 == languageCode && it.iso31661 == regionCode } compareByDescending<TmdbImage> { it.iso6391 == normalizedLanguage }
.thenByDescending { it.iso6391 == languageCode && it.iso31661 == null }
.thenByDescending { it.iso6391 == languageCode } .thenByDescending { it.iso6391 == languageCode }
.thenByDescending { it.iso6391 == "en" } .thenByDescending { it.iso6391 == "en" }
.thenByDescending { it.iso6391 == null } .thenByDescending { it.iso6391 == null }
@ -885,47 +480,27 @@ class TmdbMetadataService @Inject constructor(
?.filePath ?.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( suspend fun fetchPersonDetail(
personId: Int, personId: Int,
preferCrewCredits: Boolean? = null, preferCrewCredits: Boolean? = null
language: String = "en"
): PersonDetail? = ): PersonDetail? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val normalizedLanguage = normalizeTmdbLanguage(language) val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}"
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}:$normalizedLanguage"
personCache[cacheKey]?.let { return@withContext it } personCache[cacheKey]?.let { return@withContext it }
try { try {
val (person, credits) = coroutineScope { val (person, credits) = coroutineScope {
val personDeferred = async { val personDeferred = async {
tmdbApi.getPersonDetails(personId, TMDB_API_KEY, normalizedLanguage).body() tmdbApi.getPersonDetails(personId, TMDB_API_KEY).body()
} }
val creditsDeferred = async { val creditsDeferred = async {
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY, normalizedLanguage).body() tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY).body()
} }
Pair(personDeferred.await(), creditsDeferred.await()) Pair(personDeferred.await(), creditsDeferred.await())
} }
if (person == null) return@withContext null 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 preferCrewFilmography = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment)
val castMovieCredits = mapMovieCreditsFromCast(credits?.cast.orEmpty()) val castMovieCredits = mapMovieCreditsFromCast(credits?.cast.orEmpty())
@ -947,11 +522,11 @@ class TmdbMetadataService @Inject constructor(
val detail = PersonDetail( val detail = PersonDetail(
tmdbId = person.id, tmdbId = person.id,
name = person.name ?: "Unknown", name = person.name ?: "Unknown",
biography = biography, biography = person.biography?.takeIf { it.isNotBlank() },
birthday = person.birthday?.takeIf { it.isNotBlank() }, birthday = person.birthday?.takeIf { it.isNotBlank() },
deathday = person.deathday?.takeIf { it.isNotBlank() }, deathday = person.deathday?.takeIf { it.isNotBlank() },
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() }, placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
profilePhoto = buildImageUrl(person.profilePath, "w500"), profilePhoto = buildProfileUrl(person.profilePath),
knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() }, knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() },
movieCredits = movieCredits, movieCredits = movieCredits,
tvCredits = tvCredits tvCredits = tvCredits
@ -983,9 +558,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = ContentType.MOVIE, type = ContentType.MOVIE,
name = title, name = title,
poster = buildImageUrl(credit.posterPath, "w500"), poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER, posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"), background = buildBackdropUrl(credit.backdropPath),
logo = null, logo = null,
description = credit.overview?.takeIf { it.isNotBlank() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year, releaseInfo = year,
@ -1008,9 +583,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = ContentType.MOVIE, type = ContentType.MOVIE,
name = title, name = title,
poster = buildImageUrl(credit.posterPath, "w500"), poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER, posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"), background = buildBackdropUrl(credit.backdropPath),
logo = null, logo = null,
description = credit.overview?.takeIf { it.isNotBlank() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year, releaseInfo = year,
@ -1033,9 +608,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = ContentType.SERIES, type = ContentType.SERIES,
name = title, name = title,
poster = buildImageUrl(credit.posterPath, "w500"), poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER, posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"), background = buildBackdropUrl(credit.backdropPath),
logo = null, logo = null,
description = credit.overview?.takeIf { it.isNotBlank() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year, releaseInfo = year,
@ -1058,9 +633,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = ContentType.SERIES, type = ContentType.SERIES,
name = title, name = title,
poster = buildImageUrl(credit.posterPath, "w500"), poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER, posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"), background = buildBackdropUrl(credit.backdropPath),
logo = null, logo = null,
description = credit.overview?.takeIf { it.isNotBlank() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year, releaseInfo = year,
@ -1141,11 +716,8 @@ data class TmdbEnrichment(
val productionCompanies: List<MetaCompany>, val productionCompanies: List<MetaCompany>,
val networks: List<MetaCompany>, val networks: List<MetaCompany>,
val ageRating: String?, val ageRating: String?,
val status: String?,
val countries: List<String>?, val countries: List<String>?,
val language: String?, val language: String?
val collectionId: Int?,
val collectionName: String?
) )
data class TmdbEpisodeEnrichment( data class TmdbEpisodeEnrichment(
@ -1156,62 +728,10 @@ data class TmdbEpisodeEnrichment(
val runtimeMinutes: Int? 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 { private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
val title = name?.takeIf { it.isNotBlank() } val title = name?.takeIf { it.isNotBlank() }
val overview = overview?.takeIf { it.isNotBlank() } val overview = overview?.takeIf { it.isNotBlank() }
val thumbnail = stillPath?.takeIf { it.isNotBlank() }?.let { "https://image.tmdb.org/t/p/w500$it" } val thumbnail = stillPath?.takeIf { it.isNotBlank() }?.let { "https://image.tmdb.org/t/p/w300$it" }
val airDate = airDate?.takeIf { it.isNotBlank() } val airDate = airDate?.takeIf { it.isNotBlank() }
return TmdbEpisodeEnrichment( return TmdbEpisodeEnrichment(
title = title, title = title,

View file

@ -1,10 +1,7 @@
package com.nuvio.tv.core.tmdb package com.nuvio.tv.core.tmdb
import android.util.Log import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.TmdbApi import com.nuvio.tv.data.remote.api.TmdbApi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@ -14,7 +11,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "TmdbService" 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. * Service to handle TMDB ID conversions and lookups.
@ -29,9 +26,6 @@ class TmdbService @Inject constructor(
// Cache: TMDB ID -> IMDB ID // Cache: TMDB ID -> IMDB ID
private val tmdbToImdbCache = ConcurrentHashMap<Int, String>() 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 // Mutex for thread-safe cache operations
private val cacheMutex = Mutex() private val cacheMutex = Mutex()
@ -56,13 +50,6 @@ class TmdbService @Inject constructor(
return@withContext cached 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 { try {
Log.d(TAG, "Looking up TMDB ID for IMDB: $imdbId (type: $mediaType)") Log.d(TAG, "Looking up TMDB ID for IMDB: $imdbId (type: $mediaType)")
@ -74,17 +61,13 @@ class TmdbService @Inject constructor(
if (!response.isSuccessful) { if (!response.isSuccessful) {
Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}") Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
requestDeferred.complete(null)
return@withContext null return@withContext null
} }
val body = response.body() val body = response.body() ?: return@withContext null
if (body == null) {
requestDeferred.complete(null)
return@withContext null
}
// Determine which results to use based on media type // Determine which results to use based on media type
val normalizedType = normalizeMediaType(mediaType)
val result = when (normalizedType) { val result = when (normalizedType) {
"movie" -> body.movieResults?.firstOrNull() "movie" -> body.movieResults?.firstOrNull()
"tv", "series" -> body.tvResults?.firstOrNull() "tv", "series" -> body.tvResults?.firstOrNull()
@ -99,25 +82,16 @@ class TmdbService @Inject constructor(
imdbToTmdbCache[imdbId] = found.id imdbToTmdbCache[imdbId] = found.id
tmdbToImdbCache[found.id] = imdbId tmdbToImdbCache[found.id] = imdbId
} }
requestDeferred.complete(found.id)
return@withContext found.id return@withContext found.id
} }
Log.w(TAG, "No TMDB result found for IMDB: $imdbId") Log.w(TAG, "No TMDB result found for IMDB: $imdbId")
requestDeferred.complete(null)
null null
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e) Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e)
requestDeferred.complete(null)
null null
} finally {
imdbToTmdbInFlight.remove(requestKey, requestDeferred)
} }
} }
@ -135,16 +109,10 @@ class TmdbService @Inject constructor(
return@withContext cached 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 { try {
Log.d(TAG, "Looking up IMDB ID for TMDB: $tmdbId (type: $mediaType)") Log.d(TAG, "Looking up IMDB ID for TMDB: $tmdbId (type: $mediaType)")
val normalizedType = normalizeMediaType(mediaType)
val response = when (normalizedType) { val response = when (normalizedType) {
"movie" -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY) "movie" -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY)
"tv", "series" -> tmdbApi.getTvExternalIds(tmdbId, TMDB_API_KEY) "tv", "series" -> tmdbApi.getTvExternalIds(tmdbId, TMDB_API_KEY)
@ -153,15 +121,10 @@ class TmdbService @Inject constructor(
if (!response.isSuccessful) { if (!response.isSuccessful) {
Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}") Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
requestDeferred.complete(null)
return@withContext null return@withContext null
} }
val body = response.body() val body = response.body() ?: return@withContext null
if (body == null) {
requestDeferred.complete(null)
return@withContext null
}
body.imdbId?.let { imdbId -> body.imdbId?.let { imdbId ->
Log.d(TAG, "Found IMDB ID: $imdbId for TMDB: $tmdbId") Log.d(TAG, "Found IMDB ID: $imdbId for TMDB: $tmdbId")
@ -171,25 +134,16 @@ class TmdbService @Inject constructor(
tmdbToImdbCache[tmdbId] = imdbId tmdbToImdbCache[tmdbId] = imdbId
imdbToTmdbCache[imdbId] = tmdbId imdbToTmdbCache[imdbId] = tmdbId
} }
requestDeferred.complete(imdbId)
return@withContext imdbId return@withContext imdbId
} }
Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId") Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId")
requestDeferred.complete(null)
null null
} catch (e: CancellationException) {
requestDeferred.cancel(e)
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e) Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e)
requestDeferred.complete(null)
null null
} finally {
tmdbToImdbInFlight.remove(requestKey, requestDeferred)
} }
} }
@ -248,8 +202,6 @@ class TmdbService @Inject constructor(
fun clearCache() { fun clearCache() {
imdbToTmdbCache.clear() imdbToTmdbCache.clear()
tmdbToImdbCache.clear() tmdbToImdbCache.clear()
imdbToTmdbInFlight.clear()
tmdbToImdbInFlight.clear()
Log.d(TAG, "Cache cleared") Log.d(TAG, "Cache cleared")
} }
@ -260,9 +212,4 @@ class TmdbService @Inject constructor(
imdbToTmdbCache[imdbId] = tmdbId imdbToTmdbCache[imdbId] = tmdbId
tmdbToImdbCache[tmdbId] = imdbId 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 gson = Gson()
private val orderedUrlsKey = stringPreferencesKey("installed_addon_urls_ordered") private val orderedUrlsKey = stringPreferencesKey("installed_addon_urls_ordered")
private val legacyUrlsKey = stringSetPreferencesKey("installed_addon_urls") private val legacyUrlsKey = stringSetPreferencesKey("installed_addon_urls")
private val userSetNamesKey = stringPreferencesKey("addon_user_set_names")
private val manifestSuffix = "/manifest.json" private val manifestSuffix = "/manifest.json"
private fun canonicalizeUrl(url: String): String { private fun canonicalizeUrl(url: String): String {
val trimmed = url.trim().trimEnd('/') val trimmed = url.trim().trimEnd('/')
val queryStart = trimmed.indexOf('?') return if (trimmed.endsWith(manifestSuffix, ignoreCase = true)) {
val path = if (queryStart >= 0) trimmed.substring(0, queryStart) else trimmed trimmed.dropLast(manifestSuffix.length).trimEnd('/')
val query = if (queryStart >= 0) trimmed.substring(queryStart) else ""
val cleanPath = if (path.endsWith(manifestSuffix, ignoreCase = true)) {
path.dropLast(manifestSuffix.length).trimEnd('/')
} else { } else {
path.trimEnd('/') trimmed
} }
return cleanPath + query
} }
val installedAddonUrls: Flow<List<String>> = effectiveProfileIdFlow.flatMapLatest { pid -> 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( private fun getDefaultAddons(): Set<String> = setOf(
"https://v3-cinemeta.strem.io", "https://v3-cinemeta.strem.io",
"https://opensubtitles-v3.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

@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -22,6 +23,7 @@ class DebugSettingsDataStore @Inject constructor(
private val accountTabEnabledKey = booleanPreferencesKey("account_tab_enabled") private val accountTabEnabledKey = booleanPreferencesKey("account_tab_enabled")
private val syncCodeFeaturesEnabledKey = booleanPreferencesKey("sync_code_features_enabled") private val syncCodeFeaturesEnabledKey = booleanPreferencesKey("sync_code_features_enabled")
private val deviceTierOverrideKey = stringPreferencesKey("device_tier_override")
val accountTabEnabled: Flow<Boolean> = dataStore.data.map { prefs -> val accountTabEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[accountTabEnabledKey] ?: false prefs[accountTabEnabledKey] ?: false
@ -31,6 +33,10 @@ class DebugSettingsDataStore @Inject constructor(
prefs[syncCodeFeaturesEnabledKey] ?: false prefs[syncCodeFeaturesEnabledKey] ?: false
} }
val deviceTierOverride: Flow<String> = dataStore.data.map { prefs ->
prefs[deviceTierOverrideKey] ?: "auto"
}
suspend fun setAccountTabEnabled(enabled: Boolean) { suspend fun setAccountTabEnabled(enabled: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[accountTabEnabledKey] = enabled prefs[accountTabEnabledKey] = enabled
@ -42,4 +48,10 @@ class DebugSettingsDataStore @Inject constructor(
prefs[syncCodeFeaturesEnabledKey] = enabled prefs[syncCodeFeaturesEnabledKey] = enabled
} }
} }
suspend fun setDeviceTierOverride(value: String) {
dataStore.edit { prefs ->
prefs[deviceTierOverrideKey] = value
}
}
} }

View file

@ -3,22 +3,13 @@ package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey 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.Gson
import com.google.gson.reflect.TypeToken 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 com.nuvio.tv.domain.model.HomeLayout
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject 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_HEIGHT_DP = 189
private const val DEFAULT_POSTER_CARD_CORNER_RADIUS_DP = 12 private const val DEFAULT_POSTER_CARD_CORNER_RADIUS_DP = 12
private const val DEFAULT_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 3 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) = 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 heroCatalogKeysKey = stringPreferencesKey("hero_catalog_keys")
private val homeCatalogOrderKeysKey = stringPreferencesKey("home_catalog_order_keys") private val homeCatalogOrderKeysKey = stringPreferencesKey("home_catalog_order_keys")
private val disabledHomeCatalogKeysKey = stringPreferencesKey("disabled_home_catalog_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 sidebarCollapsedKey = booleanPreferencesKey("sidebar_collapsed_by_default")
private val modernSidebarEnabledKey = booleanPreferencesKey("modern_sidebar_enabled") private val modernSidebarEnabledKey = booleanPreferencesKey("modern_sidebar_enabled")
private val legacyModernSidebarEnabledKey = booleanPreferencesKey("glass_sidepanel_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 posterCardHeightDpKey = intPreferencesKey("poster_card_height_dp")
private val posterCardCornerRadiusDpKey = intPreferencesKey("poster_card_corner_radius_dp") private val posterCardCornerRadiusDpKey = intPreferencesKey("poster_card_corner_radius_dp")
private val blurUnwatchedEpisodesKey = booleanPreferencesKey("blur_unwatched_episodes") 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 detailPageTrailerButtonEnabledKey = booleanPreferencesKey("detail_page_trailer_button_enabled")
private val preferExternalMetaAddonDetailKey = booleanPreferencesKey("prefer_external_meta_addon_detail") 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> = private fun <T> profileFlow(extract: (prefs: androidx.datastore.preferences.core.Preferences) -> T): Flow<T> =
profileManager.activeProfileId.flatMapLatest { pid -> profileManager.activeProfileId.flatMapLatest { pid ->
@ -121,10 +106,6 @@ class LayoutPreferenceDataStore @Inject constructor(
parseCatalogKeys(prefs[disabledHomeCatalogKeysKey]) parseCatalogKeys(prefs[disabledHomeCatalogKeysKey])
} }
val customCatalogTitles: Flow<Map<String, String>> = profileFlow { prefs ->
parseCustomTitles(prefs[customCatalogTitlesKey])
}
val sidebarCollapsedByDefault: Flow<Boolean> = profileFlow { prefs -> val sidebarCollapsedByDefault: Flow<Boolean> = profileFlow { prefs ->
val modernSidebarEnabled = val modernSidebarEnabled =
prefs[modernSidebarEnabledKey] ?: prefs[legacyModernSidebarEnabledKey] ?: false prefs[modernSidebarEnabledKey] ?: prefs[legacyModernSidebarEnabledKey] ?: false
@ -147,10 +128,6 @@ class LayoutPreferenceDataStore @Inject constructor(
prefs[modernLandscapePostersEnabledKey] ?: false prefs[modernLandscapePostersEnabledKey] ?: false
} }
val modernHeroFullScreenBackdropEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[modernHeroFullScreenBackdropKey] ?: false
}
val heroSectionEnabled: Flow<Boolean> = profileFlow { prefs -> val heroSectionEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[heroSectionEnabledKey] ?: true prefs[heroSectionEnabledKey] ?: true
} }
@ -213,34 +190,12 @@ class LayoutPreferenceDataStore @Inject constructor(
prefs[blurUnwatchedEpisodesKey] ?: false prefs[blurUnwatchedEpisodesKey] ?: false
} }
val blurContinueWatchingNextUp: Flow<Boolean> = profileFlow { prefs ->
prefs[blurContinueWatchingNextUpKey] ?: false
}
val detailPageTrailerButtonEnabled: Flow<Boolean> = profileFlow { prefs -> val detailPageTrailerButtonEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[detailPageTrailerButtonEnabledKey] ?: false prefs[detailPageTrailerButtonEnabledKey] ?: false
} }
val preferExternalMetaAddonDetail: Flow<Boolean> = profileFlow { prefs -> val preferExternalMetaAddonDetail: Flow<Boolean> = profileFlow { prefs ->
prefs[preferExternalMetaAddonDetailKey] ?: true prefs[preferExternalMetaAddonDetailKey] ?: false
}
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
}
} }
suspend fun setLayout(layout: HomeLayout) { 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) { suspend fun setHeroSectionEnabled(enabled: Boolean) {
store().edit { prefs -> store().edit { prefs ->
prefs[heroSectionEnabledKey] = enabled 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) { suspend fun setDetailPageTrailerButtonEnabled(enabled: Boolean) {
store().edit { prefs -> store().edit { prefs ->
prefs[detailPageTrailerButtonEnabledKey] = enabled 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> { private fun parseCatalogKeys(json: String?): List<String> {
if (json.isNullOrBlank()) return emptyList() if (json.isNullOrBlank()) return emptyList()
return try { return try {
@ -476,83 +407,4 @@ class LayoutPreferenceDataStore @Inject constructor(
.distinct() .distinct()
.toList() .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 android.util.Log
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager import com.nuvio.tv.core.profile.ProfileManager
import com.google.gson.Gson import com.google.gson.Gson
@ -29,19 +28,6 @@ class LibraryPreferences @Inject constructor(
private val gson = Gson() private val gson = Gson()
private val libraryItemsKey = stringSetPreferencesKey("library_items") 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 -> val libraryItems: Flow<List<SavedLibraryItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { preferences -> 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) saved.id == item.id && saved.type.equals(item.type, ignoreCase = true)
} ?: false } ?: false
} }
val itemWithTimestamp = if (item.addedAt == 0L) item.copy(addedAt = System.currentTimeMillis()) else item preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(item)
preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(itemWithTimestamp)
} }
} }

View file

@ -19,7 +19,6 @@ import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import com.nuvio.tv.ui.util.languageCodeToName
/** /**
* Available subtitle languages * Available subtitle languages
@ -29,90 +28,52 @@ data class SubtitleLanguage(
val name: String val name: String
) )
val SubtitleLanguage.displayName: String
get() = languageCodeToName(code)
const val SUBTITLE_LANGUAGE_FORCED = "forced" const val SUBTITLE_LANGUAGE_FORCED = "forced"
val AVAILABLE_SUBTITLE_LANGUAGES = listOf( 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("en", "English"),
SubtitleLanguage("et", "Estonian"), SubtitleLanguage("es", "Spanish"),
SubtitleLanguage("tl", "Filipino"),
SubtitleLanguage("fi", "Finnish"),
SubtitleLanguage("fr", "French"), SubtitleLanguage("fr", "French"),
SubtitleLanguage("gl", "Galician"),
SubtitleLanguage("ka", "Georgian"),
SubtitleLanguage("de", "German"), 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("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", "Portuguese (Portugal)"),
SubtitleLanguage("pt-br", "Portuguese (Brazil)"), SubtitleLanguage("pt-br", "Portuguese (Brazil)"),
SubtitleLanguage("ro", "Romanian"),
SubtitleLanguage("ru", "Russian"), SubtitleLanguage("ru", "Russian"),
SubtitleLanguage("sr", "Serbian"), SubtitleLanguage("ja", "Japanese"),
SubtitleLanguage("si", "Sinhala"), 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("sk", "Slovak"),
SubtitleLanguage("sl", "Slovenian"), SubtitleLanguage("sl", "Slovenian"),
SubtitleLanguage("es", "Spanish"), SubtitleLanguage("sr", "Serbian"),
SubtitleLanguage("es-419", "Spanish (Latin America)"),
SubtitleLanguage("sw", "Swahili"),
SubtitleLanguage("sv", "Swedish"),
SubtitleLanguage("ta", "Tamil"), SubtitleLanguage("ta", "Tamil"),
SubtitleLanguage("te", "Telugu"), SubtitleLanguage("te", "Telugu"),
SubtitleLanguage("th", "Thai"), SubtitleLanguage("ml", "Malayalam"),
SubtitleLanguage("tr", "Turkish"), SubtitleLanguage("bn", "Bengali"),
SubtitleLanguage("uk", "Ukrainian"), SubtitleLanguage("mr", "Marathi"),
SubtitleLanguage("ur", "Urdu"), SubtitleLanguage("gu", "Gujarati"),
SubtitleLanguage("uz", "Uzbek"), SubtitleLanguage("kn", "Kannada"),
SubtitleLanguage("vi", "Vietnamese"), SubtitleLanguage("pa", "Punjabi")
SubtitleLanguage("cy", "Welsh"),
SubtitleLanguage("zu", "Zulu")
) )
/** /**
@ -150,7 +111,6 @@ data class BufferSettings(
object AudioLanguageOption { object AudioLanguageOption {
const val DEFAULT = "default" // Use media file default const val DEFAULT = "default" // Use media file default
const val DEVICE = "device" // Use device locale 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( data class PlayerSettings(
val playerPreference: PlayerPreference = PlayerPreference.INTERNAL, val playerPreference: PlayerPreference = PlayerPreference.INTERNAL,
val internalPlayerEngine: InternalPlayerEngine = InternalPlayerEngine.EXOPLAYER,
val autoSwitchInternalPlayerOnError: Boolean = false,
val useLibass: Boolean = false, val useLibass: Boolean = false,
val libassRenderType: LibassRenderType = LibassRenderType.OVERLAY_OPEN_GL, val libassRenderType: LibassRenderType = LibassRenderType.OVERLAY_OPEN_GL,
val subtitleStyle: SubtitleStyleSettings = SubtitleStyleSettings(), 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 decoderPriority: Int = 1, // EXTENSION_RENDERER_MODE_ON (0=off, 1=on, 2=prefer)
val tunnelingEnabled: Boolean = false, val tunnelingEnabled: Boolean = false,
val skipSilence: Boolean = false, val skipSilence: Boolean = false,
val audioAmplificationDb: Int = 0,
val persistAudioAmplification: Boolean = false,
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE, val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
val secondaryPreferredAudioLanguage: String? = null,
val loadingOverlayEnabled: Boolean = true, val loadingOverlayEnabled: Boolean = true,
val showPlayerLoadingStatus: Boolean = true,
val pauseOverlayEnabled: Boolean = true, val pauseOverlayEnabled: Boolean = true,
val osdClockEnabled: Boolean = true, val osdClockEnabled: Boolean = true,
val skipIntroEnabled: Boolean = true, val skipIntroEnabled: Boolean = true,
// Dolby Vision Profile 7 → HEVC fallback (requires forked ExoPlayer) // Dolby Vision Profile 7 → HEVC fallback (requires forked ExoPlayer)
val mapDV7ToHevc: Boolean = false, val mapDV7ToHevc: Boolean = false,
val mpvHardwareDecodeMode: MpvHardwareDecodeMode = MpvHardwareDecodeMode.AUTO_SAFE,
// Display settings // Display settings
val frameRateMatchingMode: FrameRateMatchingMode = FrameRateMatchingMode.OFF, val frameRateMatchingMode: FrameRateMatchingMode = FrameRateMatchingMode.OFF,
val resolutionMatchingEnabled: Boolean = false,
// Stream selection settings // Stream selection settings
val streamAutoPlayMode: StreamAutoPlayMode = StreamAutoPlayMode.MANUAL, val streamAutoPlayMode: StreamAutoPlayMode = StreamAutoPlayMode.MANUAL,
val streamAutoPlaySource: StreamAutoPlaySource = StreamAutoPlaySource.ALL_SOURCES, val streamAutoPlaySource: StreamAutoPlaySource = StreamAutoPlaySource.ALL_SOURCES,
@ -190,16 +142,13 @@ data class PlayerSettings(
val streamAutoPlaySelectedPlugins: Set<String> = emptySet(), val streamAutoPlaySelectedPlugins: Set<String> = emptySet(),
val streamAutoPlayRegex: String = "", val streamAutoPlayRegex: String = "",
val streamAutoPlayNextEpisodeEnabled: Boolean = false, val streamAutoPlayNextEpisodeEnabled: Boolean = false,
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = true, val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = false,
val streamAutoPlayTimeoutSeconds: Int = 3,
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE, val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
val nextEpisodeThresholdPercent: Float = 99f, val nextEpisodeThresholdPercent: Float = 98f,
val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f, val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f,
val streamReuseLastLinkEnabled: Boolean = false, val streamReuseLastLinkEnabled: Boolean = false,
val streamReuseLastLinkCacheHours: Int = 24, val streamReuseLastLinkCacheHours: Int = 24,
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE, val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE
val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES,
val resizeMode: Int = 0
) )
enum class StreamAutoPlayMode { enum class StreamAutoPlayMode {
@ -231,31 +180,12 @@ enum class SubtitleOrganizationMode {
BY_ADDON 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 { enum class PlayerPreference {
INTERNAL, INTERNAL,
EXTERNAL, EXTERNAL,
ASK_EVERY_TIME ASK_EVERY_TIME
} }
enum class InternalPlayerEngine {
EXOPLAYER,
MVP_PLAYER
}
/** /**
* Enum representing the different libass render types * Enum representing the different libass render types
* Maps to io.github.peerless2012.ass.media.type.AssRenderType * Maps to io.github.peerless2012.ass.media.type.AssRenderType
@ -275,8 +205,6 @@ class PlayerSettingsDataStore @Inject constructor(
) { ) {
companion object { companion object {
private const val FEATURE = "player_settings" 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) = private fun store(profileId: Int = profileManager.activeProfileId.value) =
@ -286,9 +214,6 @@ class PlayerSettingsDataStore @Inject constructor(
// Player preference key // Player preference key
private val playerPreferenceKey = stringPreferencesKey("player_preference") 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 // Libass settings keys
private val useLibassKey = booleanPreferencesKey("use_libass") private val useLibassKey = booleanPreferencesKey("use_libass")
@ -298,20 +223,14 @@ class PlayerSettingsDataStore @Inject constructor(
private val decoderPriorityKey = intPreferencesKey("decoder_priority") private val decoderPriorityKey = intPreferencesKey("decoder_priority")
private val tunnelingEnabledKey = booleanPreferencesKey("tunneling_enabled") private val tunnelingEnabledKey = booleanPreferencesKey("tunneling_enabled")
private val skipSilenceKey = booleanPreferencesKey("skip_silence") 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 preferredAudioLanguageKey = stringPreferencesKey("preferred_audio_language")
private val secondaryPreferredAudioLanguageKey = stringPreferencesKey("secondary_preferred_audio_language")
private val loadingOverlayEnabledKey = booleanPreferencesKey("loading_overlay_enabled") private val loadingOverlayEnabledKey = booleanPreferencesKey("loading_overlay_enabled")
private val showPlayerLoadingStatusKey = booleanPreferencesKey("show_player_loading_status")
private val pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled") private val pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled")
private val osdClockEnabledKey = booleanPreferencesKey("osd_clock_enabled") private val osdClockEnabledKey = booleanPreferencesKey("osd_clock_enabled")
private val skipIntroEnabledKey = booleanPreferencesKey("skip_intro_enabled") private val skipIntroEnabledKey = booleanPreferencesKey("skip_intro_enabled")
private val mapDV7ToHevcKey = booleanPreferencesKey("map_dv7_to_hevc") 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 frameRateMatchingKey = booleanPreferencesKey("frame_rate_matching")
private val frameRateMatchingModeKey = stringPreferencesKey("frame_rate_matching_mode") 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 streamAutoPlayModeKey = stringPreferencesKey("stream_auto_play_mode")
private val streamAutoPlaySourceKey = stringPreferencesKey("stream_auto_play_source") private val streamAutoPlaySourceKey = stringPreferencesKey("stream_auto_play_source")
private val streamAutoPlaySelectedAddonsKey = stringSetPreferencesKey("stream_auto_play_selected_addons") 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 streamAutoPlayRegexKey = stringPreferencesKey("stream_auto_play_regex")
private val streamAutoPlayNextEpisodeEnabledKey = booleanPreferencesKey("stream_auto_play_next_episode_enabled") private val streamAutoPlayNextEpisodeEnabledKey = booleanPreferencesKey("stream_auto_play_next_episode_enabled")
private val streamAutoPlayPreferBingeGroupForNextEpisodeKey = booleanPreferencesKey("stream_auto_play_prefer_bingegroup_next_episode") 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 nextEpisodeThresholdModeKey = stringPreferencesKey("next_episode_threshold_mode")
private val nextEpisodeThresholdPercentLegacyKey = intPreferencesKey("next_episode_threshold_percent") private val nextEpisodeThresholdPercentLegacyKey = intPreferencesKey("next_episode_threshold_percent")
private val nextEpisodeThresholdMinutesBeforeEndLegacyKey = intPreferencesKey("next_episode_threshold_minutes_before_end") 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 streamReuseLastLinkEnabledKey = booleanPreferencesKey("stream_reuse_last_link_enabled")
private val streamReuseLastLinkCacheHoursKey = intPreferencesKey("stream_reuse_last_link_cache_hours") private val streamReuseLastLinkCacheHoursKey = intPreferencesKey("stream_reuse_last_link_cache_hours")
private val subtitleOrganizationModeKey = stringPreferencesKey("subtitle_organization_mode") 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 // Subtitle style settings keys
private val subtitlePreferredLanguageKey = stringPreferencesKey("subtitle_preferred_language") 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] val preferredSubtitleLanguage = prefs[subtitlePreferredLanguageKey]
if (preferredSubtitleLanguage != null) { if (preferredSubtitleLanguage != null) {
val normalizedPreferredSubtitleLanguage = val normalizedPreferredSubtitleLanguage =
@ -431,10 +334,6 @@ class PlayerSettingsDataStore @Inject constructor(
playerPreference = prefs[playerPreferenceKey]?.let { playerPreference = prefs[playerPreferenceKey]?.let {
runCatching { PlayerPreference.valueOf(it) }.getOrDefault(PlayerPreference.INTERNAL) runCatching { PlayerPreference.valueOf(it) }.getOrDefault(PlayerPreference.INTERNAL)
} ?: 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, useLibass = prefs[useLibassKey] ?: false,
libassRenderType = prefs[libassRenderTypeKey]?.let { libassRenderType = prefs[libassRenderTypeKey]?.let {
try { LibassRenderType.valueOf(it) } catch (e: Exception) { LibassRenderType.OVERLAY_OPEN_GL } try { LibassRenderType.valueOf(it) } catch (e: Exception) { LibassRenderType.OVERLAY_OPEN_GL }
@ -442,23 +341,14 @@ class PlayerSettingsDataStore @Inject constructor(
decoderPriority = prefs[decoderPriorityKey] ?: 1, decoderPriority = prefs[decoderPriorityKey] ?: 1,
tunnelingEnabled = prefs[tunnelingEnabledKey] ?: false, tunnelingEnabled = prefs[tunnelingEnabledKey] ?: false,
skipSilence = prefs[skipSilenceKey] ?: false, skipSilence = prefs[skipSilenceKey] ?: false,
audioAmplificationDb = (prefs[audioAmplificationDbKey] ?: 0).coerceIn(
AUDIO_AMPLIFICATION_DB_MIN,
AUDIO_AMPLIFICATION_DB_MAX
),
persistAudioAmplification = prefs[persistAudioAmplificationKey] ?: false,
preferredAudioLanguage = normalizeSelectableLanguageCode( preferredAudioLanguage = normalizeSelectableLanguageCode(
prefs[preferredAudioLanguageKey] ?: AudioLanguageOption.DEVICE prefs[preferredAudioLanguageKey] ?: AudioLanguageOption.DEVICE
), ),
secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
?.let(::normalizeSecondaryAudioLanguageCode),
loadingOverlayEnabled = prefs[loadingOverlayEnabledKey] ?: true, loadingOverlayEnabled = prefs[loadingOverlayEnabledKey] ?: true,
showPlayerLoadingStatus = prefs[showPlayerLoadingStatusKey] ?: true,
pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true, pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true,
osdClockEnabled = prefs[osdClockEnabledKey] ?: true, osdClockEnabled = prefs[osdClockEnabledKey] ?: true,
skipIntroEnabled = prefs[skipIntroEnabledKey] ?: true, skipIntroEnabled = prefs[skipIntroEnabledKey] ?: true,
mapDV7ToHevc = prefs[mapDV7ToHevcKey] ?: false, mapDV7ToHevc = prefs[mapDV7ToHevcKey] ?: false,
mpvHardwareDecodeMode = parseMpvHardwareDecodeMode(prefs[mpvHardwareDecodeModeKey]),
frameRateMatchingMode = prefs[frameRateMatchingModeKey]?.let { frameRateMatchingMode = prefs[frameRateMatchingModeKey]?.let {
runCatching { FrameRateMatchingMode.valueOf(it) }.getOrNull() runCatching { FrameRateMatchingMode.valueOf(it) }.getOrNull()
} ?: if (prefs[frameRateMatchingKey] == true) { } ?: if (prefs[frameRateMatchingKey] == true) {
@ -466,7 +356,6 @@ class PlayerSettingsDataStore @Inject constructor(
} else { } else {
FrameRateMatchingMode.OFF FrameRateMatchingMode.OFF
}, },
resolutionMatchingEnabled = prefs[resolutionMatchingEnabledKey] ?: false,
streamAutoPlayMode = prefs[streamAutoPlayModeKey]?.let { streamAutoPlayMode = prefs[streamAutoPlayModeKey]?.let {
runCatching { StreamAutoPlayMode.valueOf(it) }.getOrDefault(StreamAutoPlayMode.MANUAL) runCatching { StreamAutoPlayMode.valueOf(it) }.getOrDefault(StreamAutoPlayMode.MANUAL)
} ?: StreamAutoPlayMode.MANUAL, } ?: StreamAutoPlayMode.MANUAL,
@ -478,15 +367,14 @@ class PlayerSettingsDataStore @Inject constructor(
streamAutoPlayRegex = prefs[streamAutoPlayRegexKey] ?: "", streamAutoPlayRegex = prefs[streamAutoPlayRegexKey] ?: "",
streamAutoPlayNextEpisodeEnabled = prefs[streamAutoPlayNextEpisodeEnabledKey] ?: false, streamAutoPlayNextEpisodeEnabled = prefs[streamAutoPlayNextEpisodeEnabledKey] ?: false,
streamAutoPlayPreferBingeGroupForNextEpisode = streamAutoPlayPreferBingeGroupForNextEpisode =
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: true, prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: false,
streamAutoPlayTimeoutSeconds = (prefs[streamAutoPlayTimeoutSecondsKey] ?: 3).coerceIn(0, 11),
nextEpisodeThresholdMode = prefs[nextEpisodeThresholdModeKey]?.let { nextEpisodeThresholdMode = prefs[nextEpisodeThresholdModeKey]?.let {
runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrDefault(NextEpisodeThresholdMode.PERCENTAGE) runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrDefault(NextEpisodeThresholdMode.PERCENTAGE)
} ?: NextEpisodeThresholdMode.PERCENTAGE, } ?: NextEpisodeThresholdMode.PERCENTAGE,
nextEpisodeThresholdPercent = normalizeHalfStep( nextEpisodeThresholdPercent = normalizeHalfStep(
value = prefs[nextEpisodeThresholdPercentKey] value = prefs[nextEpisodeThresholdPercentKey]
?: prefs[nextEpisodeThresholdPercentLegacyKey]?.toFloat() ?: prefs[nextEpisodeThresholdPercentLegacyKey]?.toFloat()
?: 99f, ?: 98f,
min = 97f, min = 97f,
max = 99.5f max = 99.5f
), ),
@ -500,8 +388,6 @@ class PlayerSettingsDataStore @Inject constructor(
streamReuseLastLinkEnabled = prefs[streamReuseLastLinkEnabledKey] ?: false, streamReuseLastLinkEnabled = prefs[streamReuseLastLinkEnabledKey] ?: false,
streamReuseLastLinkCacheHours = (prefs[streamReuseLastLinkCacheHoursKey] ?: 24).coerceIn(1, 168), streamReuseLastLinkCacheHours = (prefs[streamReuseLastLinkCacheHoursKey] ?: 24).coerceIn(1, 168),
subtitleOrganizationMode = parseSubtitleOrganizationMode(prefs[subtitleOrganizationModeKey]), subtitleOrganizationMode = parseSubtitleOrganizationMode(prefs[subtitleOrganizationModeKey]),
addonSubtitleStartupMode = parseAddonSubtitleStartupMode(prefs[addonSubtitleStartupModeKey]),
resizeMode = (prefs[resizeModeKey] ?: 0).coerceIn(0, 4),
subtitleStyle = SubtitleStyleSettings( subtitleStyle = SubtitleStyleSettings(
preferredLanguage = normalizeSelectableLanguageCode( preferredLanguage = normalizeSelectableLanguageCode(
prefs[subtitlePreferredLanguageKey] ?: "en" 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 // Audio settings setters
suspend fun setDecoderPriority(priority: Int) { 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) { suspend fun setPreferredAudioLanguage(language: String) {
store().edit { prefs -> store().edit { prefs ->
prefs[preferredAudioLanguageKey] = normalizeSelectableLanguageCode( 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) { suspend fun setPauseOverlayEnabled(enabled: Boolean) {
store().edit { prefs -> store().edit { prefs ->
prefs[pauseOverlayEnabledKey] = enabled 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) { suspend fun setFrameRateMatchingMode(mode: FrameRateMatchingMode) {
store().edit { prefs -> store().edit { prefs ->
prefs[frameRateMatchingModeKey] = mode.name 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) { suspend fun setFrameRateMatching(enabled: Boolean) {
setFrameRateMatchingMode( setFrameRateMatchingMode(
if (enabled) FrameRateMatchingMode.START_STOP else FrameRateMatchingMode.OFF 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) { suspend fun setNextEpisodeThresholdMode(mode: NextEpisodeThresholdMode) {
store().edit { prefs -> store().edit { prefs ->
prefs[nextEpisodeThresholdModeKey] = mode.name 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 { private fun parseSubtitleOrganizationMode(value: String?): SubtitleOrganizationMode {
return when (value) { return when (value) {
null, "NONE" -> SubtitleOrganizationMode.NONE 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 { private fun normalizeSelectableLanguageCode(language: String): String {
val code = language.trim().lowercase() val code = language.trim().lowercase()
return when (code) { 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) { suspend fun setMapDV7ToHevc(enabled: Boolean) {
store().edit { prefs -> store().edit { prefs ->
prefs[mapDV7ToHevcKey] = enabled 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 * Set whether to use libass for ASS/SSA subtitle rendering
*/ */

View file

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

View file

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

View file

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

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 androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.security.MessageDigest import java.security.MessageDigest
import javax.inject.Inject import javax.inject.Inject
@ -16,13 +15,8 @@ data class CachedStreamLink(
val streamName: String, val streamName: String,
val headers: Map<String, String>, val headers: Map<String, String>,
val cachedAtMs: Long, val cachedAtMs: Long,
val filename: String? = null, val rememberedAudioLanguage: String? = null,
val videoHash: String? = null, val rememberedAudioName: String? = null
val videoSize: Long? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val sources: List<String>? = null,
val bingeGroup: String? = null
) )
@Singleton @Singleton
@ -42,26 +36,16 @@ class StreamLinkCacheDataStore @Inject constructor(
url: String, url: String,
streamName: String, streamName: String,
headers: Map<String, String>?, headers: Map<String, String>?,
filename: String? = null, rememberedAudioLanguage: String? = null,
videoHash: String? = null, rememberedAudioName: String? = null
videoSize: Long? = null,
infoHash: String? = null,
fileIdx: Int? = null,
sources: List<String>? = null,
bingeGroup: String? = null
) { ) {
val payload = JSONObject().apply { val payload = JSONObject().apply {
put("url", url) put("url", url)
put("streamName", streamName) put("streamName", streamName)
put("cachedAtMs", System.currentTimeMillis()) put("cachedAtMs", System.currentTimeMillis())
put("headers", JSONObject(headers ?: emptyMap<String, String>())) put("headers", JSONObject(headers ?: emptyMap<String, String>()))
put("filename", filename) put("rememberedAudioLanguage", rememberedAudioLanguage)
put("videoHash", videoHash) put("rememberedAudioName", rememberedAudioName)
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) }
}.toString() }.toString()
store().edit { prefs -> store().edit { prefs ->
@ -90,30 +74,15 @@ class StreamLinkCacheDataStore @Inject constructor(
val url = json.optString("url", "") val url = json.optString("url", "")
val streamName = json.optString("streamName", "") val streamName = json.optString("streamName", "")
val infoHash = json.optString("infoHash", "").ifBlank { null } if (url.isBlank() || streamName.isBlank()) return@runCatching 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() }
CachedStreamLink( CachedStreamLink(
url = url, url = url,
streamName = streamName, streamName = streamName,
headers = headers, headers = headers,
cachedAtMs = cachedAtMs, cachedAtMs = cachedAtMs,
filename = json.optString("filename", "").ifBlank { null }, rememberedAudioLanguage = json.optString("rememberedAudioLanguage", "").ifBlank { null },
videoHash = json.optString("videoHash", "").ifBlank { null }, rememberedAudioName = json.optString("rememberedAudioName", "").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 }
) )
}.getOrNull() }.getOrNull()

View file

@ -3,7 +3,6 @@ package com.nuvio.tv.data.local
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.domain.model.AppFont
import com.nuvio.tv.domain.model.AppTheme import com.nuvio.tv.domain.model.AppTheme
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -24,7 +23,6 @@ class ThemeDataStore @Inject constructor(
factory.get(profileId, FEATURE) factory.get(profileId, FEATURE)
private val themeKey = stringPreferencesKey("selected_theme") private val themeKey = stringPreferencesKey("selected_theme")
private val fontKey = stringPreferencesKey("selected_font")
val selectedTheme: Flow<AppTheme> = profileManager.activeProfileId.flatMapLatest { pid -> val selectedTheme: Flow<AppTheme> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs -> 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) { suspend fun setTheme(theme: AppTheme) {
store().edit { prefs -> store().edit { prefs ->
prefs[themeKey] = theme.name 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) factory.get(profileId, FEATURE)
private val enabledKey = booleanPreferencesKey("tmdb_enabled") 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 languageKey = stringPreferencesKey("tmdb_language")
private val useArtworkKey = booleanPreferencesKey("tmdb_use_artwork") private val useArtworkKey = booleanPreferencesKey("tmdb_use_artwork")
private val useBasicInfoKey = booleanPreferencesKey("tmdb_use_basic_info") private val useBasicInfoKey = booleanPreferencesKey("tmdb_use_basic_info")
private val useDetailsKey = booleanPreferencesKey("tmdb_use_details") private val useDetailsKey = booleanPreferencesKey("tmdb_use_details")
private val useReleaseDatesKey = booleanPreferencesKey("tmdb_use_release_dates")
private val useCreditsKey = booleanPreferencesKey("tmdb_use_credits") private val useCreditsKey = booleanPreferencesKey("tmdb_use_credits")
private val useProductionsKey = booleanPreferencesKey("tmdb_use_productions") private val useProductionsKey = booleanPreferencesKey("tmdb_use_productions")
private val useNetworksKey = booleanPreferencesKey("tmdb_use_networks") private val useNetworksKey = booleanPreferencesKey("tmdb_use_networks")
private val useEpisodesKey = booleanPreferencesKey("tmdb_use_episodes") private val useEpisodesKey = booleanPreferencesKey("tmdb_use_episodes")
private val useMoreLikeThisKey = booleanPreferencesKey("tmdb_use_more_like_this") private val useMoreLikeThisKey = booleanPreferencesKey("tmdb_use_more_like_this")
private val useCollectionsKey = booleanPreferencesKey("tmdb_use_collections")
val settings: Flow<TmdbSettings> = profileManager.activeProfileId.flatMapLatest { pid -> val settings: Flow<TmdbSettings> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs -> factory.get(pid, FEATURE).data.map { prefs ->
TmdbSettings( TmdbSettings(
enabled = prefs[enabledKey] ?: false, enabled = prefs[enabledKey] ?: false,
modernHomeEnabled = prefs[modernHomeEnabledKey] ?: false,
enrichContinueWatching = prefs[enrichContinueWatchingKey] ?: true,
language = prefs[languageKey] ?: "en", language = prefs[languageKey] ?: "en",
useArtwork = prefs[useArtworkKey] ?: true, useArtwork = prefs[useArtworkKey] ?: true,
useBasicInfo = prefs[useBasicInfoKey] ?: true, useBasicInfo = prefs[useBasicInfoKey] ?: true,
useDetails = prefs[useDetailsKey] ?: true, useDetails = prefs[useDetailsKey] ?: true,
useReleaseDates = prefs[useReleaseDatesKey] ?: true,
useCredits = prefs[useCreditsKey] ?: true, useCredits = prefs[useCreditsKey] ?: true,
useProductions = prefs[useProductionsKey] ?: true, useProductions = prefs[useProductionsKey] ?: true,
useNetworks = prefs[useNetworksKey] ?: true, useNetworks = prefs[useNetworksKey] ?: true,
useEpisodes = prefs[useEpisodesKey] ?: true, useEpisodes = prefs[useEpisodesKey] ?: true,
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true, useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true
useCollections = prefs[useCollectionsKey] ?: true
) )
} }
} }
@ -63,14 +55,6 @@ class TmdbSettingsDataStore @Inject constructor(
store().edit { it[enabledKey] = enabled } 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) { suspend fun setLanguage(language: String) {
store().edit { it[languageKey] = language.ifBlank { "en" } } store().edit { it[languageKey] = language.ifBlank { "en" } }
} }
@ -87,10 +71,6 @@ class TmdbSettingsDataStore @Inject constructor(
store().edit { it[useDetailsKey] = enabled } store().edit { it[useDetailsKey] = enabled }
} }
suspend fun setUseReleaseDates(enabled: Boolean) {
store().edit { it[useReleaseDatesKey] = enabled }
}
suspend fun setUseCredits(enabled: Boolean) { suspend fun setUseCredits(enabled: Boolean) {
store().edit { it[useCreditsKey] = enabled } store().edit { it[useCreditsKey] = enabled }
} }
@ -110,8 +90,4 @@ class TmdbSettingsDataStore @Inject constructor(
suspend fun setUseMoreLikeThis(enabled: Boolean) { suspend fun setUseMoreLikeThis(enabled: Boolean) {
store().edit { it[useMoreLikeThisKey] = enabled } 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 -> val settings: Flow<TrailerSettings> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs -> factory.get(pid, FEATURE).data.map { prefs ->
TrailerSettings( TrailerSettings(
enabled = prefs[enabledKey] ?: true, enabled = prefs[enabledKey] ?: false,
delaySeconds = prefs[delaySecondsKey] ?: 7 delaySeconds = prefs[delaySecondsKey] ?: 7
) )
} }
@ -44,6 +44,6 @@ class TrailerSettingsDataStore @Inject constructor(
} }
data class TrailerSettings( data class TrailerSettings(
val enabled: Boolean = true, val enabled: Boolean = false,
val delaySeconds: Int = 7 val delaySeconds: Int = 7
) )

View file

@ -1,25 +1,26 @@
package com.nuvio.tv.data.local 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.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.nuvio.tv.core.profile.ProfileManager import com.nuvio.tv.core.profile.ProfileManager
import com.nuvio.tv.data.remote.dto.trakt.TraktDeviceCodeResponseDto import com.nuvio.tv.data.remote.dto.trakt.TraktDeviceCodeResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktTokenResponseDto 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.Flow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS = 86_400 private val Context.traktAuthDataStore: DataStore<Preferences> by preferencesDataStore(
name = "trakt_auth_store"
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)
}
data class TraktAuthState( data class TraktAuthState(
val accessToken: String? = null, val accessToken: String? = null,
@ -40,15 +41,10 @@ data class TraktAuthState(
} }
@Singleton @Singleton
@OptIn(ExperimentalCoroutinesApi::class)
class TraktAuthDataStore @Inject constructor( class TraktAuthDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory, @ApplicationContext private val context: Context,
private val profileManager: ProfileManager private val profileManager: ProfileManager
) { ) {
companion object {
private const val FEATURE = "trakt_auth_store"
}
private val accessTokenKey = stringPreferencesKey("access_token") private val accessTokenKey = stringPreferencesKey("access_token")
private val refreshTokenKey = stringPreferencesKey("refresh_token") private val refreshTokenKey = stringPreferencesKey("refresh_token")
private val tokenTypeKey = stringPreferencesKey("token_type") private val tokenTypeKey = stringPreferencesKey("token_type")
@ -64,44 +60,44 @@ class TraktAuthDataStore @Inject constructor(
private val expiresAtKey = longPreferencesKey("expires_at") private val expiresAtKey = longPreferencesKey("expires_at")
private val pollIntervalKey = intPreferencesKey("poll_interval") private val pollIntervalKey = intPreferencesKey("poll_interval")
private fun store(profileId: Int = profileManager.activeProfileId.value) = val state: Flow<TraktAuthState> = context.traktAuthDataStore.data.map { preferences ->
factory.get(profileId, FEATURE) TraktAuthState(
accessToken = preferences[accessTokenKey],
val state: Flow<TraktAuthState> = profileManager.activeProfileId.flatMapLatest { profileId -> refreshToken = preferences[refreshTokenKey],
store(profileId).data.map { preferences -> tokenType = preferences[tokenTypeKey],
TraktAuthState( createdAt = preferences[createdAtKey],
accessToken = preferences[accessTokenKey], expiresIn = preferences[expiresInKey],
refreshToken = preferences[refreshTokenKey], username = preferences[usernameKey],
tokenType = preferences[tokenTypeKey], userSlug = preferences[userSlugKey],
createdAt = preferences[createdAtKey], deviceCode = preferences[deviceCodeKey],
expiresIn = preferences[expiresInKey]?.let(::normalizeTraktTokenLifetimeSeconds), userCode = preferences[userCodeKey],
username = preferences[usernameKey], verificationUrl = preferences[verificationUrlKey],
userSlug = preferences[userSlugKey], expiresAt = preferences[expiresAtKey],
deviceCode = preferences[deviceCodeKey], pollInterval = preferences[pollIntervalKey]
userCode = preferences[userCodeKey], )
verificationUrl = preferences[verificationUrlKey],
expiresAt = preferences[expiresAtKey],
pollInterval = preferences[pollIntervalKey]
)
}
} }
val isAuthenticated: Flow<Boolean> = state.map { it.isAuthenticated } 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) { suspend fun saveToken(token: TraktTokenResponseDto) {
store().edit { preferences -> context.traktAuthDataStore.edit { preferences ->
preferences[accessTokenKey] = token.accessToken preferences[accessTokenKey] = token.accessToken
preferences[refreshTokenKey] = token.refreshToken preferences[refreshTokenKey] = token.refreshToken
preferences[tokenTypeKey] = token.tokenType preferences[tokenTypeKey] = token.tokenType
preferences[createdAtKey] = token.createdAt preferences[createdAtKey] = token.createdAt
preferences[expiresInKey] = normalizeTraktTokenLifetimeSeconds(token.expiresIn) preferences[expiresInKey] = token.expiresIn
} }
} }
suspend fun saveUser(username: String?, userSlug: String?) { suspend fun saveUser(username: String?, userSlug: String?) {
store().edit { preferences -> context.traktAuthDataStore.edit { preferences ->
if (username.isNullOrBlank()) { if (username.isNullOrBlank()) {
preferences.remove(usernameKey) preferences.remove(usernameKey)
} else { } else {
@ -117,7 +113,7 @@ class TraktAuthDataStore @Inject constructor(
suspend fun saveDeviceFlow(data: TraktDeviceCodeResponseDto) { suspend fun saveDeviceFlow(data: TraktDeviceCodeResponseDto) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
store().edit { preferences -> context.traktAuthDataStore.edit { preferences ->
preferences[deviceCodeKey] = data.deviceCode preferences[deviceCodeKey] = data.deviceCode
preferences[userCodeKey] = data.userCode preferences[userCodeKey] = data.userCode
preferences[verificationUrlKey] = data.verificationUrl preferences[verificationUrlKey] = data.verificationUrl
@ -127,13 +123,13 @@ class TraktAuthDataStore @Inject constructor(
} }
suspend fun updatePollInterval(seconds: Int) { suspend fun updatePollInterval(seconds: Int) {
store().edit { preferences -> context.traktAuthDataStore.edit { preferences ->
preferences[pollIntervalKey] = seconds preferences[pollIntervalKey] = seconds
} }
} }
suspend fun clearDeviceFlow() { suspend fun clearDeviceFlow() {
store().edit { preferences -> context.traktAuthDataStore.edit { preferences ->
preferences.remove(deviceCodeKey) preferences.remove(deviceCodeKey)
preferences.remove(userCodeKey) preferences.remove(userCodeKey)
preferences.remove(verificationUrlKey) preferences.remove(verificationUrlKey)
@ -143,7 +139,7 @@ class TraktAuthDataStore @Inject constructor(
} }
suspend fun clearAuth() { suspend fun clearAuth() {
store().edit { preferences -> context.traktAuthDataStore.edit { preferences ->
preferences.remove(accessTokenKey) preferences.remove(accessTokenKey)
preferences.remove(refreshTokenKey) preferences.remove(refreshTokenKey)
preferences.remove(tokenTypeKey) 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.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager import com.nuvio.tv.core.profile.ProfileManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 @Singleton
@OptIn(ExperimentalCoroutinesApi::class)
class TraktSettingsDataStore @Inject constructor( class TraktSettingsDataStore @Inject constructor(
private val factory: ProfileDataStoreFactory, private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager private val profileManager: ProfileManager
@ -35,8 +21,6 @@ class TraktSettingsDataStore @Inject constructor(
const val CONTINUE_WATCHING_DAYS_CAP_ALL = 0 const val CONTINUE_WATCHING_DAYS_CAP_ALL = 0
const val DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60 const val DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60
const val DEFAULT_SHOW_UNAIRED_NEXT_UP = true 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 MIN_CONTINUE_WATCHING_DAYS_CAP = 7
const val MAX_CONTINUE_WATCHING_DAYS_CAP = 365 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 continueWatchingDaysCapKey = intPreferencesKey("continue_watching_days_cap")
private val dismissedNextUpKeysKey = stringSetPreferencesKey("dismissed_next_up_keys") private val dismissedNextUpKeysKey = stringSetPreferencesKey("dismissed_next_up_keys")
private val showUnairedNextUpKey = booleanPreferencesKey("show_unaired_next_up") 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 -> val continueWatchingDaysCap: Flow<Int> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs -> 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) { suspend fun setContinueWatchingDaysCap(days: Int) {
store().edit { prefs -> store().edit { prefs ->
prefs[continueWatchingDaysCapKey] = normalizeContinueWatchingDaysCap(days) 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) { suspend fun setShowUnairedNextUp(enabled: Boolean) {
store().edit { prefs -> store().edit { prefs ->
prefs[showUnairedNextUpKey] = enabled 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
}
}
} }

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