Compare commits
No commits in common. "dev" and "trakt" have entirely different histories.
398 changed files with 10118 additions and 129447 deletions
220
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
220
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -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.
|
||||
78
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
78
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -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
|
||||
42
.github/PULL_REQUEST_TEMPLATE.md
vendored
42
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -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 -->
|
||||
182
.github/workflows/beta-release.yml
vendored
182
.github/workflows/beta-release.yml
vendored
|
|
@ -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[@]}"
|
||||
83
.github/workflows/close-unlabeled-issues.yml
vendored
83
.github/workflows/close-unlabeled-issues.yml
vendored
|
|
@ -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.`);
|
||||
}
|
||||
75
.github/workflows/pr-template-check.yml
vendored
75
.github/workflows/pr-template-check.yml
vendored
|
|
@ -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.");
|
||||
}
|
||||
|
||||
75
.github/workflows/stale-needs-info.yml
vendored
75
.github/workflows/stale-needs-info.yml
vendored
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
164
.github/workflows/triage-needs-info.yml
vendored
164
.github/workflows/triage-needs-info.yml
vendored
|
|
@ -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
8
.gitignore
vendored
|
|
@ -37,12 +37,8 @@ benchmark
|
|||
db/
|
||||
backups/
|
||||
.supabase_db.env
|
||||
scripts/*
|
||||
!scripts/release_beta.py
|
||||
/supabase
|
||||
scripts
|
||||
supabase
|
||||
tv-samples
|
||||
HOME_UI_OPTIMIZATION_NOTES.local.md
|
||||
.kotlin/
|
||||
bugs/
|
||||
avatars/
|
||||
logs
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-03-30T03:43:38.199872Z">
|
||||
<DropdownSelection timestamp="2026-02-03T18:03:33.281364Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amarjitsingh/.android/avd/Television_1080p.avd" />
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/nayifnoushad/.android/avd/Television_1080p.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/baselineprofile" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -2,5 +2,17 @@
|
|||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/Player" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/android-tv-samples" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/ass" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/expat" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/fontconfig" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/freetype" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/freetype/subprojects/dlg" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/fribidi" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/harfbuzz" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/unibreak" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
plugins {
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
id("io.sentry.android.gradle") version "6.0.0"
|
||||
}
|
||||
|
||||
import java.util.Properties
|
||||
|
|
@ -24,28 +25,16 @@ val devProperties = Properties().apply {
|
|||
}
|
||||
}
|
||||
|
||||
fun env(name: String): String? = providers.environmentVariable(name).orNull
|
||||
|
||||
val useDebugReleaseSigning = env("CI_USE_DEBUG_SIGNING").equals("true", ignoreCase = true)
|
||||
val releaseStoreFilePath = env("NUVIO_RELEASE_STORE_FILE")
|
||||
?: localProperties.getProperty("NUVIO_RELEASE_STORE_FILE")
|
||||
val releaseKeyAliasValue = env("NUVIO_RELEASE_KEY_ALIAS")
|
||||
?: localProperties.getProperty("NUVIO_RELEASE_KEY_ALIAS", "nuviotv")
|
||||
val releaseKeyPasswordValue = env("NUVIO_RELEASE_KEY_PASSWORD")
|
||||
?: localProperties.getProperty("NUVIO_RELEASE_KEY_PASSWORD", "815787")
|
||||
val releaseStorePasswordValue = env("NUVIO_RELEASE_STORE_PASSWORD")
|
||||
?: localProperties.getProperty("NUVIO_RELEASE_STORE_PASSWORD", "815787")
|
||||
|
||||
android {
|
||||
namespace = "com.nuvio.tv"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.nuvio.tv"
|
||||
minSdk = 24
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 54
|
||||
versionName = "0.6.3-beta"
|
||||
versionCode = 23
|
||||
versionName = "0.4.5-beta"
|
||||
|
||||
buildConfigField("String", "PARENTAL_GUIDE_API_URL", "\"${localProperties.getProperty("PARENTAL_GUIDE_API_URL", "")}\"")
|
||||
buildConfigField("String", "INTRODB_API_URL", "\"${localProperties.getProperty("INTRODB_API_URL", "")}\"")
|
||||
|
|
@ -55,12 +44,7 @@ android {
|
|||
buildConfigField("String", "TRAKT_CLIENT_ID", "\"${localProperties.getProperty("TRAKT_CLIENT_ID", "")}\"")
|
||||
buildConfigField("String", "TRAKT_CLIENT_SECRET", "\"${localProperties.getProperty("TRAKT_CLIENT_SECRET", "")}\"")
|
||||
buildConfigField("String", "TRAKT_API_URL", "\"${localProperties.getProperty("TRAKT_API_URL", "https://api.trakt.tv/")}\"")
|
||||
buildConfigField("String", "TRAKT_REDIRECT_URI", "\"${localProperties.getProperty("TRAKT_REDIRECT_URI", "urn:ietf:wg:oauth:2.0:oob")}\"")
|
||||
buildConfigField("String", "TMDB_API_KEY", "\"${localProperties.getProperty("TMDB_API_KEY", "")}\"")
|
||||
buildConfigField("String", "TV_LOGIN_WEB_BASE_URL", "\"${localProperties.getProperty("TV_LOGIN_WEB_BASE_URL", "https://app.nuvio.tv/tv-login")}\"")
|
||||
buildConfigField("String", "DONATIONS_BASE_URL", "\"${localProperties.getProperty("DONATIONS_BASE_URL", "")}\"")
|
||||
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${localProperties.getProperty("DONATIONS_DONATE_URL", "")}\"")
|
||||
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", "")}\"")
|
||||
|
||||
// In-app updater (GitHub Releases)
|
||||
buildConfigField("String", "GITHUB_OWNER", "\"tapframe\"")
|
||||
|
|
@ -69,10 +53,10 @@ android {
|
|||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = releaseKeyAliasValue
|
||||
keyPassword = releaseKeyPasswordValue
|
||||
storeFile = releaseStoreFilePath?.let(::file) ?: file("../nuviotv.jks")
|
||||
storePassword = releaseStorePasswordValue
|
||||
keyAlias = "nuviotv"
|
||||
keyPassword = "815787"
|
||||
storeFile = file("../nuviotv.jks")
|
||||
storePassword = "815787"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,22 +77,14 @@ android {
|
|||
buildConfigField("String", "TRAILER_API_URL", "\"${devProperties.getProperty("TRAILER_API_URL", "")}\"")
|
||||
buildConfigField("String", "IMDB_RATINGS_API_BASE_URL", "\"${devProperties.getProperty("IMDB_RATINGS_API_BASE_URL", "")}\"")
|
||||
buildConfigField("String", "IMDB_TAPFRAME_API_BASE_URL", "\"${devProperties.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}\"")
|
||||
buildConfigField("String", "DONATIONS_BASE_URL", "\"${devProperties.getProperty("DONATIONS_BASE_URL", localProperties.getProperty("DONATIONS_BASE_URL", ""))}\"")
|
||||
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${devProperties.getProperty("DONATIONS_DONATE_URL", localProperties.getProperty("DONATIONS_DONATE_URL", ""))}\"")
|
||||
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${devProperties.getProperty("AVATAR_PUBLIC_BASE_URL", localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", ""))}\"")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = if (useDebugReleaseSigning) {
|
||||
signingConfigs.getByName("debug")
|
||||
} else {
|
||||
signingConfigs.getByName("release")
|
||||
}
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
|
||||
buildConfigField("boolean", "IS_DEBUG_BUILD", "false")
|
||||
|
||||
|
|
@ -121,22 +97,6 @@ android {
|
|||
buildConfigField("String", "TRAILER_API_URL", "\"${localProperties.getProperty("TRAILER_API_URL", "")}\"")
|
||||
buildConfigField("String", "IMDB_RATINGS_API_BASE_URL", "\"${localProperties.getProperty("IMDB_RATINGS_API_BASE_URL", "")}\"")
|
||||
buildConfigField("String", "IMDB_TAPFRAME_API_BASE_URL", "\"${localProperties.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}\"")
|
||||
buildConfigField("String", "DONATIONS_BASE_URL", "\"${localProperties.getProperty("DONATIONS_BASE_URL", "")}\"")
|
||||
buildConfigField("String", "DONATIONS_DONATE_URL", "\"${localProperties.getProperty("DONATIONS_DONATE_URL", "")}\"")
|
||||
buildConfigField("String", "AVATAR_PUBLIC_BASE_URL", "\"${localProperties.getProperty("AVATAR_PUBLIC_BASE_URL", "")}\"")
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(buildTypes.getByName("release"))
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
applicationIdSuffix = ".debug"
|
||||
matchingFallbacks += "release"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +112,6 @@ android {
|
|||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
|
|
@ -163,35 +122,6 @@ android {
|
|||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Keep one consistent native set across dependencies.
|
||||
pickFirsts += listOf(
|
||||
"lib/*/libc++_shared.so",
|
||||
"lib/*/libavcodec.so",
|
||||
"lib/*/libavdevice.so",
|
||||
"lib/*/libavfilter.so",
|
||||
"lib/*/libavformat.so",
|
||||
"lib/*/libavutil.so",
|
||||
"lib/*/libswscale.so",
|
||||
"lib/*/libswresample.so",
|
||||
"lib/*/libtorrserver.so"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("debug")) { variant ->
|
||||
variant.applicationId.set("com.nuviodebug.com")
|
||||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
|
|
@ -207,21 +137,10 @@ configurations.all {
|
|||
exclude(group = "androidx.media3", module = "media3-ui")
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
automaticGenerationDuringBuild = false
|
||||
saveInSrc = true
|
||||
mergeIntoMain = true
|
||||
baselineProfileOutputDir = "src/main"
|
||||
filter {
|
||||
include("com.nuvio.tv.**")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
|
||||
|
||||
baselineProfile(project(":baselineprofile"))
|
||||
// baselineProfile(project(":benchmark")) // TODO: create benchmark module later
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
|
@ -257,7 +176,6 @@ dependencies {
|
|||
|
||||
// Image Loading
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.gif)
|
||||
implementation(libs.coil.svg)
|
||||
|
||||
// Navigation
|
||||
|
|
@ -296,9 +214,6 @@ dependencies {
|
|||
|
||||
// libass-android for ASS/SSA subtitle support (from Maven Central)
|
||||
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
|
||||
// Local nextlib-mediainfo fork (static FFmpeg; no libav*.so in final AAR)
|
||||
implementation(files("libs/nextlib-mediainfo-local.aar"))
|
||||
implementation("io.github.abdallahmehiz:mpv-android-lib:0.1.12")
|
||||
implementation("dev.chrisbanes.haze:haze-android:0.7.3") {
|
||||
exclude(group = "org.jetbrains.compose.ui")
|
||||
exclude(group = "org.jetbrains.compose.foundation")
|
||||
|
|
@ -309,26 +224,6 @@ dependencies {
|
|||
implementation(libs.jsoup)
|
||||
implementation(libs.gson)
|
||||
|
||||
// Jackson — required by CloudStream DEX extensions at runtime
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
|
||||
|
||||
// NiceHTTP — HTTP client used by CloudStream extensions via `app.get()`
|
||||
implementation(libs.nicehttp)
|
||||
|
||||
// Conscrypt — TLS provider with browser-compatible fingerprint for Cloudflare bypass
|
||||
implementation(libs.conscrypt.android)
|
||||
|
||||
// CloudStream library — provides core API classes for extension compatibility
|
||||
implementation(libs.cloudstream.library) {
|
||||
// Exclude heavy deps we don't need or that conflict
|
||||
exclude(group = "org.mozilla", module = "rhino")
|
||||
exclude(group = "com.github.AmarullisVFX", module = "newpipeextractor")
|
||||
exclude(group = "com.github.AmaryllisVFX", module = "newpipeextractor")
|
||||
exclude(group = "com.github.AmaryllisVFX.newpipeextractor")
|
||||
exclude(group = "info.debatty", module = "java-string-similarity")
|
||||
}
|
||||
|
||||
// Markdown rendering
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
|
||||
|
|
@ -338,7 +233,6 @@ dependencies {
|
|||
implementation(libs.nanohttpd)
|
||||
implementation(libs.zxing.core)
|
||||
|
||||
|
||||
// Supabase
|
||||
implementation(platform(libs.supabase.bom))
|
||||
implementation(libs.supabase.auth)
|
||||
|
|
@ -349,8 +243,8 @@ dependencies {
|
|||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Performance profiling
|
||||
implementation("androidx.metrics:metrics-performance:1.0.0-rc01") // JankStats
|
||||
debugImplementation("androidx.compose.runtime:runtime-tracing")
|
||||
implementation("androidx.metrics:metrics-performance:1.0.0-beta01") // JankStats
|
||||
implementation("androidx.compose.runtime:runtime-tracing") // Compose function names in Perfetto
|
||||
|
||||
// Bundle real crypto-js (JS) for QuickJS plugins
|
||||
implementation("org.webjars.npm:crypto-js:4.2.0")
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
43
app/proguard-rules.pro
vendored
43
app/proguard-rules.pro
vendored
|
|
@ -25,8 +25,6 @@
|
|||
# Keep Retrofit service interfaces (must preserve generic return types)
|
||||
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||
# NOTE: allowobfuscation here is fine for Retrofit, but superseded by the
|
||||
# broader kotlin.** keep rule below for DexClassLoader extension compatibility.
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
# Keep all project API interfaces
|
||||
-keep class com.nuvio.tv.data.remote.api.** { *; }
|
||||
|
|
@ -55,9 +53,6 @@
|
|||
# Keep server classes and their inner data classes (serialized with Gson)
|
||||
-keep class com.nuvio.tv.core.server.** { *; }
|
||||
|
||||
# ── Torrent streaming (TorrServer) ─────────────────────────────────────────────
|
||||
-keep class com.nuvio.tv.core.torrent.** { *; }
|
||||
|
||||
#── QuickJS ────────────────────────────────────────────────────────────────────
|
||||
# Keep quickjs-kt library classes for proper type conversion
|
||||
-keep class com.dokar.quickjs.** { *; }
|
||||
|
|
@ -88,45 +83,7 @@
|
|||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# ── External extension compatibility stubs (loaded via DexClassLoader) ────────
|
||||
-keep class com.lagradost.cloudstream3.** { *; }
|
||||
-keepclassmembers class com.lagradost.cloudstream3.** { *; }
|
||||
-keep class com.lagradost.nicehttp.** { *; }
|
||||
-keepclassmembers class com.lagradost.nicehttp.** { *; }
|
||||
-keep class com.lagradost.api.** { *; }
|
||||
-keepclassmembers class com.lagradost.api.** { *; }
|
||||
|
||||
# ── General ────────────────────────────────────────────────────────────────────
|
||||
# Keep line numbers for crash reports
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
# MPV (native JNI callbacks)
|
||||
# Native code reflects into multiple classes/methods under is.xyz.mpv,
|
||||
# so keep the whole package to avoid JNI lookup crashes after R8.
|
||||
-keep class is.xyz.mpv.** { *; }
|
||||
|
||||
# ── Missing class stubs (referenced by cloudstream3 / jsoup / newpipe) ────────
|
||||
-dontwarn org.mozilla.javascript.**
|
||||
-dontwarn com.google.re2j.**
|
||||
-dontwarn javax.script.**
|
||||
-dontwarn okhttp3.internal.sse.**
|
||||
-dontwarn org.jsoup.helper.Re2jRegex
|
||||
|
||||
# ── DexClassLoader runtime deps (CloudStream extensions) ─────────────────────
|
||||
# Extensions are DEX files loaded at runtime via DexClassLoader. They resolve
|
||||
# dependencies by fully-qualified name from the host classloader. R8 must not
|
||||
# rename or remove any class that extensions may reference.
|
||||
-keep class kotlin.** { *; }
|
||||
-keep class kotlinx.coroutines.** { *; }
|
||||
|
||||
-keep class okhttp3.** { *; }
|
||||
-keepclassmembers class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
-keepclassmembers class okio.** { *; }
|
||||
-keep class org.jsoup.** { *; }
|
||||
-keepclassmembers class org.jsoup.** { *; }
|
||||
-keep class com.fasterxml.jackson.** { *; }
|
||||
-keepclassmembers class com.fasterxml.jackson.** { *; }
|
||||
-dontwarn java.beans.ConstructorProperties
|
||||
-dontwarn java.beans.Transient
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<resources>
|
||||
<string name="app_name">Nuvio Debug</string>
|
||||
</resources>
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
|
@ -20,13 +19,18 @@
|
|||
android:allowBackup="true"
|
||||
android:banner="@mipmap/banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:label="Nuvio"
|
||||
android:largeHeap="true"
|
||||
android:extractNativeLibs="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:supportsRtl="true"
|
||||
android:localeConfig="@xml/locale_config"
|
||||
android:theme="@style/Theme.MyApplication">
|
||||
<meta-data
|
||||
android:name="io.sentry.dsn"
|
||||
android:value="https://151aefbf907d5fd4a6c8533d9d064441@o4510894222278656.ingest.us.sentry.io/4510894227259392" />
|
||||
<meta-data
|
||||
android:name="io.sentry.send-default-pii"
|
||||
android:value="true" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
|
@ -40,7 +44,6 @@
|
|||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|uiMode|keyboard|keyboardHidden|navigation|layoutDirection|fontScale|locale"
|
||||
android:theme="@style/Theme.MyApplication.Launcher">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package com.nuvio.tv
|
|||
import android.os.Bundle
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.metrics.performance.JankStats
|
||||
|
|
@ -28,7 +27,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
|||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -56,7 +54,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -85,9 +82,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -101,7 +96,6 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
|||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.ModalNavigationDrawer
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.SurfaceDefaults
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.rememberDrawerState
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
|
|
@ -110,13 +104,10 @@ import com.nuvio.tv.data.local.AppOnboardingDataStore
|
|||
import com.nuvio.tv.data.local.LayoutPreferenceDataStore
|
||||
import com.nuvio.tv.data.local.ThemeDataStore
|
||||
import com.nuvio.tv.data.repository.TraktProgressService
|
||||
import com.nuvio.tv.domain.model.AppFont
|
||||
import com.nuvio.tv.domain.model.AppTheme
|
||||
import com.nuvio.tv.domain.model.AuthState
|
||||
import com.nuvio.tv.core.sync.ProfileSettingsSyncService
|
||||
import com.nuvio.tv.core.sync.ProfileSyncService
|
||||
import com.nuvio.tv.core.sync.StartupSyncService
|
||||
import com.nuvio.tv.data.remote.supabase.AvatarRepository
|
||||
import com.nuvio.tv.ui.navigation.NuvioNavHost
|
||||
import com.nuvio.tv.ui.navigation.Screen
|
||||
import com.nuvio.tv.ui.components.NuvioScrollDefaults
|
||||
|
|
@ -141,9 +132,6 @@ import coil.request.ImageRequest
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import com.nuvio.tv.R
|
||||
|
||||
val LocalSidebarExpanded = compositionLocalOf { false }
|
||||
val LocalContentFocusRequester = compositionLocalOf { FocusRequester.Default }
|
||||
|
||||
data class DrawerItem(
|
||||
val route: String,
|
||||
val label: String,
|
||||
|
|
@ -153,7 +141,6 @@ data class DrawerItem(
|
|||
|
||||
private data class MainUiPrefs(
|
||||
val theme: AppTheme = AppTheme.WHITE,
|
||||
val font: AppFont = AppFont.INTER,
|
||||
val hasChosenLayout: Boolean? = null,
|
||||
val sidebarCollapsed: Boolean = false,
|
||||
val modernSidebarEnabled: Boolean = false,
|
||||
|
|
@ -175,9 +162,6 @@ class MainActivity : ComponentActivity() {
|
|||
@Inject
|
||||
lateinit var startupSyncService: StartupSyncService
|
||||
|
||||
@Inject
|
||||
lateinit var profileSettingsSyncService: ProfileSettingsSyncService
|
||||
|
||||
@Inject
|
||||
lateinit var profileSyncService: ProfileSyncService
|
||||
|
||||
|
|
@ -190,9 +174,6 @@ class MainActivity : ComponentActivity() {
|
|||
@Inject
|
||||
lateinit var appOnboardingDataStore: AppOnboardingDataStore
|
||||
|
||||
@Inject
|
||||
lateinit var avatarRepository: AvatarRepository
|
||||
|
||||
private lateinit var jankStats: JankStats
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
|
|
@ -206,9 +187,6 @@ class MainActivity : ComponentActivity() {
|
|||
config.setLocale(locale)
|
||||
super.attachBaseContext(newBase.createConfigurationContext(config))
|
||||
} else {
|
||||
val systemLocale = ConfigurationCompat.getLocales(newBase.resources.configuration)[0]
|
||||
?: Locale.getDefault(Locale.Category.DISPLAY)
|
||||
Locale.setDefault(systemLocale)
|
||||
super.attachBaseContext(newBase)
|
||||
}
|
||||
}
|
||||
|
|
@ -217,12 +195,8 @@ class MainActivity : ComponentActivity() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
window?.setBackgroundDrawable(null)
|
||||
|
||||
// Store Activity reference for CloudStream extensions that need it in plugin.load()
|
||||
com.lagradost.cloudstream3.AcraApplication.setActivity(this)
|
||||
setContent {
|
||||
var hasSelectedProfileThisSession by rememberSaveable { mutableStateOf(false) }
|
||||
var hasSelectedProfileThisSession by remember { mutableStateOf(false) }
|
||||
var onboardingCompletedThisSession by remember { mutableStateOf(false) }
|
||||
var onboardingProfileSyncInProgress by remember { mutableStateOf(false) }
|
||||
val hasSeenAuthQrOnFirstLaunch by appOnboardingDataStore
|
||||
|
|
@ -243,63 +217,33 @@ class MainActivity : ComponentActivity() {
|
|||
val activeProfile = remember(activeProfileId, profiles) {
|
||||
profiles.firstOrNull { it.id == activeProfileId }
|
||||
}
|
||||
var profilePinStates by remember { mutableStateOf<Map<Int, Boolean>>(emptyMap()) }
|
||||
|
||||
LaunchedEffect(authState, profiles) {
|
||||
if (authState is AuthState.FullAccount) {
|
||||
profileSyncService.pullProfileLockStates()
|
||||
.onSuccess { profilePinStates = it }
|
||||
.onFailure { profilePinStates = emptyMap() }
|
||||
} else {
|
||||
profilePinStates = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
val activeProfileHasPin = remember(activeProfileId, profilePinStates) {
|
||||
profilePinStates[activeProfileId] == true
|
||||
}
|
||||
var avatarCatalog by remember { mutableStateOf(emptyList<com.nuvio.tv.data.remote.supabase.AvatarCatalogItem>()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
avatarCatalog = runCatching { avatarRepository.getAvatarCatalog() }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
val activeProfileAvatarImageUrl = remember(activeProfile, avatarCatalog) {
|
||||
activeProfile?.avatarId?.let { avatarRepository.getAvatarImageUrl(it, avatarCatalog) }
|
||||
}
|
||||
|
||||
val mainUiPrefsFlow = remember(themeDataStore, layoutPreferenceDataStore) {
|
||||
combine(
|
||||
themeDataStore.selectedTheme,
|
||||
themeDataStore.selectedFont,
|
||||
layoutPreferenceDataStore.hasChosenLayout,
|
||||
layoutPreferenceDataStore.sidebarCollapsedByDefault,
|
||||
layoutPreferenceDataStore.modernSidebarEnabled,
|
||||
) { theme, font, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled ->
|
||||
layoutPreferenceDataStore.modernSidebarBlurEnabled
|
||||
) { theme, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled, modernSidebarBlurPref ->
|
||||
MainUiPrefs(
|
||||
theme = theme,
|
||||
font = font,
|
||||
hasChosenLayout = hasChosenLayout,
|
||||
sidebarCollapsed = sidebarCollapsed,
|
||||
modernSidebarEnabled = modernSidebarEnabled,
|
||||
modernSidebarBlurPref = modernSidebarBlurPref
|
||||
)
|
||||
}.combine(layoutPreferenceDataStore.modernSidebarBlurEnabled) { prefs, modernSidebarBlurPref ->
|
||||
prefs.copy(modernSidebarBlurPref = modernSidebarBlurPref)
|
||||
}
|
||||
}
|
||||
val mainUiPrefs by mainUiPrefsFlow.collectAsState(initial = MainUiPrefs(hasChosenLayout = null))
|
||||
|
||||
NuvioTheme(appTheme = mainUiPrefs.theme, appFont = mainUiPrefs.font) {
|
||||
NuvioTheme(appTheme = mainUiPrefs.theme) {
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides NuvioScrollDefaults.smoothScrollSpec
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = RectangleShape,
|
||||
colors = SurfaceDefaults.colors(
|
||||
containerColor = NuvioColors.Background
|
||||
)
|
||||
shape = RectangleShape
|
||||
) {
|
||||
if (hasSeenAuthQrOnFirstLaunch == null) {
|
||||
Box(
|
||||
|
|
@ -357,7 +301,7 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
|
||||
val shouldShowProfileSelection =
|
||||
!hasSelectedProfileThisSession && (profiles.size > 1 || activeProfileHasPin)
|
||||
!hasSelectedProfileThisSession && profiles.size > 1
|
||||
|
||||
if (shouldShowProfileSelection) {
|
||||
ProfileSelectionScreen(
|
||||
|
|
@ -471,8 +415,6 @@ class MainActivity : ComponentActivity() {
|
|||
hideBuiltInHeaders = hideBuiltInHeadersForFloatingPill,
|
||||
activeProfileName = activeProfile?.name ?: "",
|
||||
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
|
||||
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
|
||||
showProfileSelector = profiles.size > 1,
|
||||
onSwitchProfile = { hasSelectedProfileThisSession = false },
|
||||
onExitApp = {
|
||||
finishAffinity()
|
||||
|
|
@ -491,8 +433,6 @@ class MainActivity : ComponentActivity() {
|
|||
hideBuiltInHeaders = false,
|
||||
activeProfileName = activeProfile?.name ?: "",
|
||||
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
|
||||
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
|
||||
showProfileSelector = profiles.size > 1,
|
||||
onSwitchProfile = { hasSelectedProfileThisSession = false },
|
||||
onExitApp = {
|
||||
finishAffinity()
|
||||
|
|
@ -527,10 +467,6 @@ class MainActivity : ComponentActivity() {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (::jankStats.isInitialized) jankStats.isTrackingEnabled = true
|
||||
startupSyncService.requestSyncNow(includeProfileSettings = false)
|
||||
lifecycleScope.launch {
|
||||
traktProgressService.refreshNow()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
|
@ -540,12 +476,10 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
profileSettingsSyncService.requestForegroundPull()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
com.lagradost.cloudstream3.AcraApplication.setActivity(null)
|
||||
startupSyncService.requestSyncNow()
|
||||
lifecycleScope.launch {
|
||||
traktProgressService.refreshNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -562,8 +496,6 @@ private fun LegacySidebarScaffold(
|
|||
hideBuiltInHeaders: Boolean,
|
||||
activeProfileName: String,
|
||||
activeProfileColorHex: String,
|
||||
activeProfileAvatarImageUrl: String?,
|
||||
showProfileSelector: Boolean,
|
||||
onSwitchProfile: () -> Unit,
|
||||
onExitApp: () -> Unit
|
||||
) {
|
||||
|
|
@ -578,11 +510,9 @@ private fun LegacySidebarScaffold(
|
|||
}
|
||||
|
||||
val closedDrawerWidth = if (sidebarCollapsed) 0.dp else 72.dp
|
||||
val openDrawerWidth = 196.dp
|
||||
val openDrawerWidth = 216.dp
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
var pendingContentFocusTransfer by remember { mutableStateOf(false) }
|
||||
var pendingSidebarFocusRequest by remember { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -600,7 +530,7 @@ private fun LegacySidebarScaffold(
|
|||
return@LaunchedEffect
|
||||
}
|
||||
repeat(2) { withFrameNanos { } }
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
focusManager.moveFocus(FocusDirection.Right)
|
||||
pendingContentFocusTransfer = false
|
||||
}
|
||||
|
||||
|
|
@ -634,8 +564,7 @@ private fun LegacySidebarScaffold(
|
|||
.padding(12.dp)
|
||||
.selectableGroup()
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
val closeKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
|
||||
if (keyEvent.key == closeKey && keyEvent.type == KeyEventType.KeyDown) {
|
||||
if (keyEvent.key == Key.DirectionRight && keyEvent.type == KeyEventType.KeyDown) {
|
||||
drawerState.setValue(DrawerValue.Closed)
|
||||
pendingContentFocusTransfer = false
|
||||
true
|
||||
|
|
@ -645,69 +574,21 @@ private fun LegacySidebarScaffold(
|
|||
}
|
||||
) {
|
||||
val isExpanded = drawerValue == DrawerValue.Open
|
||||
val itemWidth = if (isExpanded) 156.dp else 48.dp
|
||||
|
||||
if (isExpanded) {
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
if (showProfileSelector && activeProfileName.isNotEmpty()) {
|
||||
var isProfileFocused by remember { mutableStateOf(false) }
|
||||
val profileItemShape = RoundedCornerShape(32.dp)
|
||||
val profileLeadingInset = 18.dp
|
||||
val profileAvatarSize = 34.dp
|
||||
val profileLabelStart = 60.dp
|
||||
val profileGapAfterAvatar =
|
||||
(profileLabelStart - profileLeadingInset - profileAvatarSize).coerceAtLeast(0.dp)
|
||||
val profileBgColor by animateColorAsState(
|
||||
targetValue = if (isProfileFocused) NuvioColors.FocusBackground else Color.Transparent,
|
||||
label = "legacyProfileItemBg"
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.width(itemWidth)
|
||||
.height(52.dp)
|
||||
.background(color = profileBgColor, shape = profileItemShape)
|
||||
.onFocusChanged { isProfileFocused = it.isFocused }
|
||||
.clickable {
|
||||
onSwitchProfile()
|
||||
drawerState.setValue(DrawerValue.Closed)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(profileLeadingInset))
|
||||
ProfileAvatarCircle(
|
||||
name = activeProfileName,
|
||||
colorHex = activeProfileColorHex,
|
||||
size = profileAvatarSize,
|
||||
avatarImageUrl = activeProfileAvatarImageUrl
|
||||
)
|
||||
Spacer(modifier = Modifier.width(profileGapAfterAvatar))
|
||||
Text(
|
||||
text = activeProfileName,
|
||||
color = if (isProfileFocused) NuvioColors.TextPrimary else NuvioColors.TextSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Start,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo_wordmark),
|
||||
contentDescription = "NuvioTV",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(42.dp)
|
||||
)
|
||||
}
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo_wordmark),
|
||||
contentDescription = "NuvioTV",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(42.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
val itemWidth = if (isExpanded) 176.dp else 48.dp
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
|
|
@ -737,28 +618,64 @@ private fun LegacySidebarScaffold(
|
|||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (isExpanded && activeProfileName.isNotEmpty()) {
|
||||
var isProfileFocused by remember { mutableStateOf(false) }
|
||||
val profileItemShape = RoundedCornerShape(32.dp)
|
||||
val profileBgColor by animateColorAsState(
|
||||
targetValue = if (isProfileFocused) NuvioColors.FocusBackground else Color.Transparent,
|
||||
label = "legacyProfileItemBg"
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.width(itemWidth)
|
||||
.height(52.dp)
|
||||
.clip(profileItemShape)
|
||||
.background(color = profileBgColor, shape = profileItemShape)
|
||||
.onFocusChanged { isProfileFocused = it.isFocused }
|
||||
.clickable {
|
||||
onSwitchProfile()
|
||||
drawerState.setValue(DrawerValue.Closed)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
ProfileAvatarCircle(
|
||||
name = activeProfileName,
|
||||
colorHex = activeProfileColorHex,
|
||||
size = 34.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = activeProfileName,
|
||||
color = if (isProfileFocused) NuvioColors.TextPrimary else NuvioColors.TextSecondary,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val contentStartPadding by animateDpAsState(
|
||||
targetValue = if (showSidebar) closedDrawerWidth else 0.dp,
|
||||
animationSpec = tween(350),
|
||||
label = "contentStartPadding"
|
||||
)
|
||||
val contentStartPadding = if (showSidebar) closedDrawerWidth else 0.dp
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = contentStartPadding)
|
||||
.onKeyEvent { keyEvent ->
|
||||
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
|
||||
if (
|
||||
showSidebar &&
|
||||
drawerState.currentValue == DrawerValue.Closed &&
|
||||
keyEvent.type == KeyEventType.KeyDown &&
|
||||
keyEvent.key == openKey
|
||||
keyEvent.key == Key.DirectionLeft
|
||||
) {
|
||||
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
|
||||
if (focusManager.moveFocus(FocusDirection.Left)) {
|
||||
true
|
||||
} else {
|
||||
pendingSidebarFocusRequest = true
|
||||
|
|
@ -770,16 +687,11 @@ private fun LegacySidebarScaffold(
|
|||
}
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSidebarExpanded provides (drawerState.currentValue == DrawerValue.Open),
|
||||
LocalContentFocusRequester provides contentFocusRequester
|
||||
) {
|
||||
NuvioNavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
hideBuiltInHeaders = hideBuiltInHeaders
|
||||
)
|
||||
}
|
||||
NuvioNavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
hideBuiltInHeaders = hideBuiltInHeaders
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -799,7 +711,7 @@ private fun LegacySidebarButton(
|
|||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> NuvioColors.FocusBackground
|
||||
expanded && selected -> NuvioColors.Secondary
|
||||
selected -> NuvioColors.BackgroundCard
|
||||
else -> Color.Transparent
|
||||
},
|
||||
label = "legacySidebarItemBackground"
|
||||
|
|
@ -807,26 +719,17 @@ private fun LegacySidebarButton(
|
|||
val contentColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> NuvioColors.TextPrimary
|
||||
expanded && selected -> NuvioColors.OnSecondary
|
||||
selected -> NuvioColors.TextPrimary
|
||||
else -> NuvioColors.TextSecondary
|
||||
},
|
||||
label = "legacySidebarItemContent"
|
||||
)
|
||||
val iconTint by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> NuvioColors.TextPrimary
|
||||
expanded && selected -> NuvioColors.OnSecondary
|
||||
selected -> NuvioColors.Secondary
|
||||
!expanded -> NuvioColors.TextTertiary
|
||||
else -> NuvioColors.TextSecondary
|
||||
},
|
||||
label = "legacySidebarItemIconTint"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(52.dp)
|
||||
.focusProperties { canFocus = expanded }
|
||||
.clip(itemShape)
|
||||
.background(color = backgroundColor, shape = itemShape)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable(onClick = onClick),
|
||||
|
|
@ -834,7 +737,7 @@ private fun LegacySidebarButton(
|
|||
DrawerItemIcon(
|
||||
iconRes = iconRes,
|
||||
icon = icon,
|
||||
tint = iconTint,
|
||||
tint = contentColor,
|
||||
modifier = if (expanded) {
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
|
|
@ -876,8 +779,6 @@ private fun ModernSidebarScaffold(
|
|||
hideBuiltInHeaders: Boolean,
|
||||
activeProfileName: String,
|
||||
activeProfileColorHex: String,
|
||||
activeProfileAvatarImageUrl: String?,
|
||||
showProfileSelector: Boolean,
|
||||
onSwitchProfile: () -> Unit,
|
||||
onExitApp: () -> Unit
|
||||
) {
|
||||
|
|
@ -886,8 +787,6 @@ private fun ModernSidebarScaffold(
|
|||
val openSidebarWidth = 262.dp
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
val drawerItemFocusRequesters = remember(drawerItems) {
|
||||
drawerItems.associate { item -> item.route to FocusRequester() }
|
||||
}
|
||||
|
|
@ -901,8 +800,6 @@ private fun ModernSidebarScaffold(
|
|||
val keepFloatingPillExpanded = selectedDrawerRoute == Screen.Settings.route
|
||||
val keepSidebarFocusDuringCollapse =
|
||||
isSidebarExpanded || sidebarCollapsePending || pendingContentFocusTransfer
|
||||
val hasSidebarProfileItem = showProfileSelector && activeProfileName.isNotEmpty()
|
||||
val sidebarTopBoundaryIndex = if (hasSidebarProfileItem) drawerItems.size else 0
|
||||
|
||||
LaunchedEffect(showSidebar) {
|
||||
if (!showSidebar) {
|
||||
|
|
@ -1057,7 +954,7 @@ private fun ModernSidebarScaffold(
|
|||
return@LaunchedEffect
|
||||
}
|
||||
repeat(2) { withFrameNanos { } }
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
focusManager.moveFocus(FocusDirection.Right)
|
||||
pendingContentFocusTransfer = false
|
||||
}
|
||||
|
||||
|
|
@ -1082,6 +979,13 @@ private fun ModernSidebarScaffold(
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (shouldApplySidebarHaze) {
|
||||
Modifier.haze(sidebarHazeState)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (
|
||||
isSidebarExpanded &&
|
||||
|
|
@ -1104,9 +1008,8 @@ private fun ModernSidebarScaffold(
|
|||
else -> Unit
|
||||
}
|
||||
}
|
||||
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
|
||||
if (keyEvent.key == openKey) {
|
||||
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
|
||||
if (keyEvent.key == Key.DirectionLeft) {
|
||||
if (focusManager.moveFocus(FocusDirection.Left)) {
|
||||
true
|
||||
} else {
|
||||
isSidebarExpanded = true
|
||||
|
|
@ -1122,16 +1025,11 @@ private fun ModernSidebarScaffold(
|
|||
}
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSidebarExpanded provides isSidebarExpanded,
|
||||
LocalContentFocusRequester provides contentFocusRequester
|
||||
) {
|
||||
NuvioNavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
hideBuiltInHeaders = hideBuiltInHeaders
|
||||
)
|
||||
}
|
||||
NuvioNavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
hideBuiltInHeaders = hideBuiltInHeaders
|
||||
)
|
||||
}
|
||||
|
||||
if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) {
|
||||
|
|
@ -1162,22 +1060,17 @@ private fun ModernSidebarScaffold(
|
|||
}
|
||||
when (keyEvent.key) {
|
||||
Key.DirectionUp -> {
|
||||
focusedDrawerIndex == sidebarTopBoundaryIndex
|
||||
focusedDrawerIndex == 0
|
||||
}
|
||||
|
||||
Key.DirectionDown -> {
|
||||
focusedDrawerIndex == drawerItems.lastIndex
|
||||
focusedDrawerIndex > drawerItems.lastIndex
|
||||
}
|
||||
|
||||
Key.DirectionRight, Key.DirectionLeft -> {
|
||||
val collapseKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
|
||||
if (keyEvent.key == collapseKey) {
|
||||
pendingContentFocusTransfer = false
|
||||
sidebarCollapsePending = true
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
Key.DirectionRight -> {
|
||||
pendingContentFocusTransfer = false
|
||||
sidebarCollapsePending = true
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
|
|
@ -1212,8 +1105,6 @@ private fun ModernSidebarScaffold(
|
|||
},
|
||||
activeProfileName = activeProfileName,
|
||||
activeProfileColorHex = activeProfileColorHex,
|
||||
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
|
||||
showProfileSelector = showProfileSelector,
|
||||
onSwitchProfile = onSwitchProfile
|
||||
)
|
||||
}
|
||||
|
|
@ -1234,7 +1125,7 @@ private fun ModernSidebarScaffold(
|
|||
.align(Alignment.TopStart)
|
||||
.offset {
|
||||
IntOffset(
|
||||
14.dp.roundToPx(),
|
||||
(40.dp + sidebarSlideX + sidebarDeflateOffsetX).roundToPx(),
|
||||
(16.dp + sidebarDeflateOffsetY).roundToPx()
|
||||
)
|
||||
}
|
||||
|
|
@ -1308,6 +1199,7 @@ private fun CollapsedSidebarPill(
|
|||
.graphicsLayer {
|
||||
shape = pillShape
|
||||
clip = true
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
.clip(pillShape)
|
||||
.background(brush = pillBackgroundBrush, shape = pillShape)
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ import androidx.compose.ui.graphics.Brush
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Icon
|
||||
|
|
@ -57,10 +57,6 @@ import coil.compose.rememberAsyncImagePainter
|
|||
import coil.decode.SvgDecoder
|
||||
import coil.request.ImageRequest
|
||||
|
||||
private val SidebarLeadingVisualSize = 34.dp
|
||||
private val SidebarContentGap = 14.dp
|
||||
private val SidebarProfileContentGap = 18.dp
|
||||
|
||||
@Composable
|
||||
internal fun ModernSidebarBlurPanel(
|
||||
drawerItems: List<DrawerItem>,
|
||||
|
|
@ -79,8 +75,6 @@ internal fun ModernSidebarBlurPanel(
|
|||
onDrawerItemClick: (String) -> Unit,
|
||||
activeProfileName: String,
|
||||
activeProfileColorHex: String,
|
||||
activeProfileAvatarImageUrl: String?,
|
||||
showProfileSelector: Boolean,
|
||||
onSwitchProfile: () -> Unit
|
||||
) {
|
||||
val delayedBlurProgress =
|
||||
|
|
@ -135,43 +129,19 @@ internal fun ModernSidebarBlurPanel(
|
|||
.border(width = 1.dp, color = panelBorderColor, shape = panelShape)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp)
|
||||
) {
|
||||
if (showProfileSelector && activeProfileName.isNotEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SidebarProfileItem(
|
||||
profileName = activeProfileName,
|
||||
profileColorHex = activeProfileColorHex,
|
||||
profileAvatarImageUrl = activeProfileAvatarImageUrl,
|
||||
focusEnabled = keepSidebarFocusDuringCollapse,
|
||||
labelAlpha = sidebarLabelAlpha,
|
||||
onFocusChanged = { focused ->
|
||||
if (focused) onDrawerItemFocused(drawerItems.size)
|
||||
},
|
||||
onClick = onSwitchProfile,
|
||||
modifier = Modifier.fillMaxWidth(0.92f)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo_wordmark),
|
||||
contentDescription = "NuvioTV",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.72f)
|
||||
.height(36.dp),
|
||||
alpha = sidebarLabelAlpha
|
||||
)
|
||||
}
|
||||
}
|
||||
val headerLogoRes = if (isSidebarExpanded) R.drawable.app_logo_wordmark else R.drawable.app_logo_mark
|
||||
val headerLogoHeight = if (isSidebarExpanded) 42.dp else 34.dp
|
||||
val headerLogoContentDescription = if (isSidebarExpanded) "NuvioTV" else "Nuvio"
|
||||
|
||||
Image(
|
||||
painter = painterResource(id = headerLogoRes),
|
||||
contentDescription = headerLogoContentDescription,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(headerLogoHeight)
|
||||
.offset(y = 12.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
|
@ -209,6 +179,25 @@ internal fun ModernSidebarBlurPanel(
|
|||
}
|
||||
}
|
||||
|
||||
if (activeProfileName.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SidebarProfileItem(
|
||||
profileName = activeProfileName,
|
||||
profileColorHex = activeProfileColorHex,
|
||||
focusEnabled = keepSidebarFocusDuringCollapse,
|
||||
labelAlpha = sidebarLabelAlpha,
|
||||
onFocusChanged = { focused ->
|
||||
if (focused) onDrawerItemFocused(drawerItems.size)
|
||||
},
|
||||
onClick = onSwitchProfile,
|
||||
modifier = Modifier.fillMaxWidth(0.92f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +233,9 @@ private fun SidebarNavigationItem(
|
|||
|
||||
val contentColor = if (selected) Color(0xFF10151F) else Color.White
|
||||
val iconCircleColor = if (selected) Color(0xFFE7E2EF) else Color(0xFF6A6A74)
|
||||
val iconContainerSize = 34.dp
|
||||
val contentGap = 14.dp
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
|
|
@ -267,7 +259,7 @@ private fun SidebarNavigationItem(
|
|||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SidebarLeadingVisualSize)
|
||||
.size(iconContainerSize)
|
||||
.clip(CircleShape)
|
||||
.background(iconCircleColor)
|
||||
.padding(6.dp)
|
||||
|
|
@ -293,7 +285,7 @@ private fun SidebarNavigationItem(
|
|||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(SidebarContentGap))
|
||||
Spacer(modifier = Modifier.width(contentGap))
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
|
|
@ -305,6 +297,7 @@ private fun SidebarNavigationItem(
|
|||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.width(iconContainerSize + contentGap))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +305,6 @@ private fun SidebarNavigationItem(
|
|||
private fun SidebarProfileItem(
|
||||
profileName: String,
|
||||
profileColorHex: String,
|
||||
profileAvatarImageUrl: String?,
|
||||
focusEnabled: Boolean,
|
||||
labelAlpha: Float,
|
||||
onFocusChanged: (Boolean) -> Unit,
|
||||
|
|
@ -331,6 +323,9 @@ private fun SidebarProfileItem(
|
|||
animationSpec = tween(durationMillis = 180),
|
||||
label = "profileItemBorder"
|
||||
)
|
||||
val contentGap = 14.dp
|
||||
val iconContainerSize = 34.dp
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
|
|
@ -352,29 +347,19 @@ private fun SidebarProfileItem(
|
|||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(SidebarLeadingVisualSize),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ProfileAvatarCircle(
|
||||
name = profileName,
|
||||
colorHex = profileColorHex,
|
||||
size = SidebarLeadingVisualSize,
|
||||
avatarImageUrl = profileAvatarImageUrl
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(SidebarProfileContentGap))
|
||||
ProfileAvatarCircle(
|
||||
name = profileName,
|
||||
colorHex = profileColorHex,
|
||||
size = iconContainerSize
|
||||
)
|
||||
Spacer(modifier = Modifier.width(contentGap))
|
||||
Text(
|
||||
text = profileName,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.graphicsLayer { alpha = labelAlpha },
|
||||
style = androidx.tv.material3.MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,13 @@
|
|||
package com.nuvio.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import android.util.Log
|
||||
import com.lagradost.cloudstream3.AcraApplication
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.nicehttp.ignoreAllSSLErrors
|
||||
import com.nuvio.tv.core.sync.StartupSyncService
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.io.File
|
||||
import java.security.Security
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
|
|
@ -31,79 +15,15 @@ class NuvioApplication : Application(), ImageLoaderFactory {
|
|||
|
||||
@Inject lateinit var startupSyncService: StartupSyncService
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Shared cookie jar for CloudStream extension HTTP requests.
|
||||
* Accessible so the player's OkHttpClient can share cookies
|
||||
* obtained during scraping (e.g., session tokens needed for playback).
|
||||
*/
|
||||
val extensionCookieJar: CookieJar = object : CookieJar {
|
||||
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return store[url.host]?.filter { cookie ->
|
||||
cookie.expiresAt > System.currentTimeMillis()
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val hostCookies = store.getOrPut(url.host) { mutableListOf() }
|
||||
cookies.forEach { newCookie ->
|
||||
hostCookies.removeAll { it.name == newCookie.name }
|
||||
hostCookies.add(newCookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Install Conscrypt as the primary TLS provider.
|
||||
// This gives OkHttp a browser-compatible TLS fingerprint (JA3/JA4),
|
||||
// which is critical for bypassing Cloudflare's bot detection.
|
||||
// Without it, Cloudflare blocks OkHttp even with valid cf_clearance cookies.
|
||||
try {
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
} catch (e: Exception) {
|
||||
Log.w("NuvioApplication", "Failed to install Conscrypt: ${e.message}")
|
||||
}
|
||||
|
||||
// Initialize the CloudStream NiceHTTP singleton's OkHttpClient.
|
||||
// Matches CloudStream's RequestsHelper.initClient() setup.
|
||||
// Wrapped in try catch because java.lang.BootstrapMethodError
|
||||
// doesn't exist on API < 26 (e.g. Fire TV 4K Gen 1 running Android 7.1.2)
|
||||
try {
|
||||
app.baseClient = OkHttpClient.Builder()
|
||||
.cookieJar(extensionCookieJar)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.ignoreAllSSLErrors()
|
||||
.cache(Cache(
|
||||
directory = File(cacheDir, "http_cache"),
|
||||
maxSize = 50L * 1024L * 1024L
|
||||
))
|
||||
.build()
|
||||
} catch (e: Throwable) {
|
||||
Log.w("NuvioApplication", "Failed to initialize NiceHttp client (API ${Build.VERSION.SDK_INT}): ${e.message}")
|
||||
}
|
||||
|
||||
// Set AcraApplication context early so CS3 stubs can access it
|
||||
AcraApplication.context = this
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.33)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.nuvio.tv.core.auth
|
|||
|
||||
import android.util.Log
|
||||
import com.nuvio.tv.BuildConfig
|
||||
import com.nuvio.tv.data.local.AuthSessionNoticeDataStore
|
||||
import com.nuvio.tv.data.remote.supabase.TvLoginExchangeResult
|
||||
import com.nuvio.tv.data.remote.supabase.TvLoginPollResult
|
||||
import com.nuvio.tv.data.remote.supabase.TvLoginStartResult
|
||||
|
|
@ -35,8 +34,7 @@ private const val TAG = "AuthManager"
|
|||
class AuthManager @Inject constructor(
|
||||
private val auth: Auth,
|
||||
private val postgrest: Postgrest,
|
||||
private val httpClient: OkHttpClient,
|
||||
private val authSessionNoticeDataStore: AuthSessionNoticeDataStore
|
||||
private val httpClient: OkHttpClient
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
|
@ -64,10 +62,8 @@ class AuthManager @Inject constructor(
|
|||
}
|
||||
if (user.email.isNullOrBlank()) {
|
||||
_authState.value = AuthState.SignedOut
|
||||
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
|
||||
} else {
|
||||
_authState.value = AuthState.FullAccount(userId = user.id, email = user.email!!)
|
||||
authSessionNoticeDataStore.markNuvioAuthenticated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -82,14 +78,12 @@ class AuthManager @Inject constructor(
|
|||
cachedEffectiveUserId = null
|
||||
cachedEffectiveUserSourceUserId = null
|
||||
_authState.value = AuthState.SignedOut
|
||||
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cachedEffectiveUserId = null
|
||||
cachedEffectiveUserSourceUserId = null
|
||||
_authState.value = AuthState.SignedOut
|
||||
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
|
||||
}
|
||||
}
|
||||
is SessionStatus.Initializing -> {
|
||||
|
|
@ -206,12 +200,7 @@ class AuthManager @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun signOut(explicit: Boolean = true) {
|
||||
if (explicit) {
|
||||
authSessionNoticeDataStore.markNuvioExplicitLogout()
|
||||
} else {
|
||||
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
|
||||
}
|
||||
suspend fun signOut() {
|
||||
try {
|
||||
auth.signOut()
|
||||
} catch (e: Exception) {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ import android.util.Log
|
|||
import com.nuvio.tv.BuildConfig
|
||||
import com.nuvio.tv.data.remote.api.AddonApi
|
||||
import com.nuvio.tv.data.remote.api.AniSkipApi
|
||||
import com.nuvio.tv.data.remote.api.AnimeSkipApi
|
||||
import com.nuvio.tv.data.remote.api.ArmApi
|
||||
import com.nuvio.tv.data.remote.api.DonationsApi
|
||||
import com.nuvio.tv.data.remote.api.GitHubReleaseApi
|
||||
import com.nuvio.tv.data.remote.api.TraktApi
|
||||
import com.nuvio.tv.data.remote.api.TrailerApi
|
||||
|
|
@ -29,17 +27,11 @@ import okhttp3.OkHttpClient
|
|||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import com.nuvio.tv.core.network.IPv4FirstDns
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
private object TraktHttpTrace {
|
||||
private val requestCounter = AtomicLong(0L)
|
||||
|
|
@ -58,39 +50,15 @@ object NetworkModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient {
|
||||
val trustAllManager = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
val sslContext = SSLContext.getInstance("TLS").apply {
|
||||
init(null, arrayOf<TrustManager>(trustAllManager), SecureRandom())
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.dns(IPv4FirstDns())
|
||||
.sslSocketFactory(sslContext.socketFactory, trustAllManager)
|
||||
.hostnameVerifier { _, _ -> true }
|
||||
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
// Prevent OkHttp from caching error responses (4xx/5xx).
|
||||
.addNetworkInterceptor { chain ->
|
||||
val response = chain.proceed(chain.request())
|
||||
if (!response.isSuccessful) {
|
||||
response.newBuilder()
|
||||
.header("Cache-Control", "no-store")
|
||||
.build()
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
|
||||
else HttpLoggingInterceptor.Level.NONE
|
||||
})
|
||||
.build()
|
||||
}
|
||||
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient = OkHttpClient.Builder()
|
||||
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
|
||||
else HttpLoggingInterceptor.Level.NONE
|
||||
})
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -262,21 +230,6 @@ object NetworkModule {
|
|||
fun provideArmApi(@Named("arm") retrofit: Retrofit): ArmApi =
|
||||
retrofit.create(ArmApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named("animeSkipGql")
|
||||
fun provideAnimeSkipGqlRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit =
|
||||
Retrofit.Builder()
|
||||
.baseUrl("https://api.anime-skip.com/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAnimeSkipApi(@Named("animeSkipGql") retrofit: Retrofit): AnimeSkipApi =
|
||||
retrofit.create(AnimeSkipApi::class.java)
|
||||
|
||||
// --- GitHub Releases API (in-app updates) ---
|
||||
|
||||
@Provides
|
||||
|
|
@ -294,26 +247,6 @@ object NetworkModule {
|
|||
fun provideGitHubReleaseApi(@Named("github") retrofit: Retrofit): GitHubReleaseApi =
|
||||
retrofit.create(GitHubReleaseApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named("donations")
|
||||
fun provideDonationsRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
|
||||
val baseUrl = BuildConfig.DONATIONS_BASE_URL
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: error("DONATIONS_BASE_URL is missing. Set it in local.properties or local.dev.properties.")
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDonationsApi(@Named("donations") retrofit: Retrofit): DonationsApi =
|
||||
retrofit.create(DonationsApi::class.java)
|
||||
|
||||
// --- Trailer API ---
|
||||
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.nuvio.tv.R
|
||||
|
||||
object ExternalPlayerLauncher {
|
||||
|
||||
|
|
@ -38,7 +37,7 @@ object ExternalPlayerLauncher {
|
|||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.player_no_external_player),
|
||||
"No external player found",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
false
|
||||
|
|
|
|||
|
|
@ -2,23 +2,18 @@ package com.nuvio.tv.core.player
|
|||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.MediaExtractor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfo
|
||||
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfoBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Auto frame rate matching utility.
|
||||
|
|
@ -27,29 +22,28 @@ import java.util.Locale
|
|||
object FrameRateUtils {
|
||||
|
||||
private const val TAG = "FrameRateUtils"
|
||||
private const val SWITCH_TIMEOUT_MS = 4000L
|
||||
private const val SWITCH_TIMEOUT_MS = 5000L
|
||||
private const val REFRESH_MATCH_MIN_TOLERANCE_HZ = 0.08f
|
||||
private const val NTSC_FILM_FPS = 24000f / 1001f
|
||||
private const val CINEMA_24_FPS = 24f
|
||||
private const val MIN_VALID_VIDEO_FPS = 10f
|
||||
private const val MAX_VALID_VIDEO_FPS = 120f
|
||||
private val NEXTLIB_HTTP_SCHEMES = setOf("http", "https")
|
||||
private val LIVE_STREAM_EXTENSIONS = listOf(".m3u8", ".mpd", ".ism/manifest")
|
||||
private const val MKV_EXTENSION = ".mkv"
|
||||
private const val SWITCH_POLL_INTERVAL_MS = 60L
|
||||
private const val SWITCH_REQUIRED_STABLE_POLLS = 2
|
||||
|
||||
private var displayManager: DisplayManager? = null
|
||||
private var displayListener: DisplayManager.DisplayListener? = null
|
||||
private var timeoutHandler: Handler? = null
|
||||
private var timeoutRunnable: Runnable? = null
|
||||
data class DisplayModeSwitchResult(
|
||||
val appliedMode: Display.Mode
|
||||
val appliedMode: Display.Mode,
|
||||
val isFallback: Boolean
|
||||
)
|
||||
|
||||
private var pendingAfterSwitch: ((DisplayModeSwitchResult) -> Unit)? = null
|
||||
private var pendingDisplayId: Int? = null
|
||||
private var pendingMode: Display.Mode? = null
|
||||
private var originalModeId: Int? = null
|
||||
|
||||
data class FrameRateDetection(
|
||||
val raw: Float,
|
||||
val snapped: Float,
|
||||
val videoWidth: Int? = null,
|
||||
val videoHeight: Int? = null
|
||||
val snapped: Float
|
||||
)
|
||||
|
||||
private fun matchesTargetRefresh(refreshRate: Float, target: Float): Boolean {
|
||||
|
|
@ -78,6 +72,35 @@ object FrameRateUtils {
|
|||
return weight
|
||||
}
|
||||
|
||||
private fun completeSwitch(reason: String) {
|
||||
Log.d(TAG, "Display mode switch completed ($reason)")
|
||||
val callback = pendingAfterSwitch
|
||||
val requestedMode = pendingMode
|
||||
val realMode = runCatching {
|
||||
val displayId = pendingDisplayId
|
||||
if (displayId != null) {
|
||||
displayManager?.getDisplay(displayId)?.mode
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
val appliedMode = realMode ?: requestedMode
|
||||
val isFallback = requestedMode != null && realMode != null && realMode.modeId != requestedMode.modeId
|
||||
cleanupDisplayListener()
|
||||
if (callback != null && appliedMode != null) {
|
||||
callback(DisplayModeSwitchResult(appliedMode = appliedMode, isFallback = isFallback))
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleSwitchTimeout() {
|
||||
timeoutHandler = Handler(Looper.getMainLooper())
|
||||
timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Display mode switch timeout after ${SWITCH_TIMEOUT_MS}ms")
|
||||
completeSwitch("timeout")
|
||||
}
|
||||
timeoutHandler?.postDelayed(timeoutRunnable!!, SWITCH_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun recordOriginalMode(display: Display) {
|
||||
if (originalModeId == null) {
|
||||
originalModeId = display.mode.modeId
|
||||
|
|
@ -128,153 +151,109 @@ object FrameRateUtils {
|
|||
}
|
||||
}
|
||||
|
||||
private fun chooseBestModeForFrameRate(
|
||||
activeMode: Display.Mode,
|
||||
modes: List<Display.Mode>,
|
||||
frameRate: Float
|
||||
): Display.Mode {
|
||||
val modeExact = pickBestForTarget(modes, frameRate)
|
||||
val modeDouble = pickBestForTarget(modes, frameRate * 2f)
|
||||
val modePulldown = pickBestForTarget(modes, frameRate * 2.5f)
|
||||
val modeFallback = modes.minByOrNull { refreshWeight(it.refreshRate, frameRate) }
|
||||
return modeExact ?: modeDouble ?: modePulldown ?: modeFallback ?: activeMode
|
||||
}
|
||||
|
||||
private fun hasValidVideoSize(videoWidth: Int?, videoHeight: Int?): Boolean {
|
||||
return (videoWidth ?: 0) > 0 && (videoHeight ?: 0) > 0
|
||||
}
|
||||
|
||||
private fun normalizedSize(width: Int, height: Int): Pair<Int, Int> {
|
||||
return if (width >= height) width to height else height to width
|
||||
}
|
||||
|
||||
private fun resolutionDistanceSquared(mode: Display.Mode, targetWidth: Int, targetHeight: Int): Long {
|
||||
val (modeWidth, modeHeight) = normalizedSize(mode.physicalWidth, mode.physicalHeight)
|
||||
val dw = modeWidth - targetWidth
|
||||
val dh = modeHeight - targetHeight
|
||||
return dw.toLong() * dw.toLong() + dh.toLong() * dh.toLong()
|
||||
}
|
||||
|
||||
private fun selectModesForVideoResolution(
|
||||
modes: List<Display.Mode>,
|
||||
videoWidth: Int,
|
||||
videoHeight: Int
|
||||
): List<Display.Mode> {
|
||||
if (modes.isEmpty()) return modes
|
||||
val (targetWidth, targetHeight) = normalizedSize(videoWidth, videoHeight)
|
||||
val minDistance = modes.minOfOrNull { resolutionDistanceSquared(it, targetWidth, targetHeight) } ?: return modes
|
||||
return modes.filter { resolutionDistanceSquared(it, targetWidth, targetHeight) == minDistance }
|
||||
}
|
||||
|
||||
suspend fun matchFrameRateAndWait(
|
||||
fun matchFrameRate(
|
||||
activity: Activity,
|
||||
frameRate: Float,
|
||||
videoWidth: Int? = null,
|
||||
videoHeight: Int? = null,
|
||||
resolutionMatchingEnabled: Boolean = false
|
||||
): DisplayModeSwitchResult? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null
|
||||
if (frameRate <= 0f) return null
|
||||
onBeforeSwitch: (() -> Unit)? = null,
|
||||
onAfterSwitch: ((DisplayModeSwitchResult) -> Unit)? = null
|
||||
): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
|
||||
if (frameRate <= 0f) return false
|
||||
|
||||
val switchPlan = withContext(Dispatchers.Main) {
|
||||
val window = activity.window ?: return@withContext null
|
||||
val display = window.decorView.display ?: return@withContext null
|
||||
return try {
|
||||
val window = activity.window ?: return false
|
||||
val display = window.decorView.display ?: return false
|
||||
val supportedModes = display.supportedModes
|
||||
val activeMode = display.mode
|
||||
|
||||
val sameSizeModes = display.supportedModes.filter {
|
||||
if (supportedModes.size <= 1) return false
|
||||
|
||||
val sameSizeModes = supportedModes.filter {
|
||||
it.physicalWidth == activeMode.physicalWidth &&
|
||||
it.physicalHeight == activeMode.physicalHeight
|
||||
}
|
||||
val candidateModes = if (resolutionMatchingEnabled && hasValidVideoSize(videoWidth, videoHeight)) {
|
||||
selectModesForVideoResolution(
|
||||
modes = display.supportedModes.toList(),
|
||||
videoWidth = videoWidth ?: activeMode.physicalWidth,
|
||||
videoHeight = videoHeight ?: activeMode.physicalHeight
|
||||
if (sameSizeModes.size <= 1) return false
|
||||
|
||||
// Kodi-like priority without user whitelist:
|
||||
// exact -> 2x -> 3:2 pulldown -> weighted fallback.
|
||||
val modeExact = pickBestForTarget(sameSizeModes, frameRate)
|
||||
val modeDouble = pickBestForTarget(sameSizeModes, frameRate * 2f)
|
||||
val modePulldown = pickBestForTarget(sameSizeModes, frameRate * 2.5f)
|
||||
val modeFallback = sameSizeModes.minByOrNull { refreshWeight(it.refreshRate, frameRate) }
|
||||
|
||||
val modeBest = modeExact ?: modeDouble ?: modePulldown ?: modeFallback ?: activeMode
|
||||
val switchNeeded = modeBest.modeId != activeMode.modeId
|
||||
|
||||
if (switchNeeded) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Switching display mode: ${activeMode.refreshRate}Hz -> ${modeBest.refreshRate}Hz " +
|
||||
"(video ${frameRate}fps)"
|
||||
)
|
||||
} else {
|
||||
sameSizeModes
|
||||
}
|
||||
if (candidateModes.isEmpty()) {
|
||||
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
|
||||
null,
|
||||
DisplayModeSwitchResult(activeMode)
|
||||
)
|
||||
}
|
||||
if (!resolutionMatchingEnabled && candidateModes.size <= 1) {
|
||||
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
|
||||
null,
|
||||
DisplayModeSwitchResult(activeMode)
|
||||
)
|
||||
}
|
||||
|
||||
val modeBest = chooseBestModeForFrameRate(
|
||||
activeMode = activeMode,
|
||||
modes = candidateModes,
|
||||
frameRate = frameRate
|
||||
)
|
||||
recordOriginalMode(display)
|
||||
if (modeBest.modeId == activeMode.modeId) {
|
||||
Log.d(TAG, "Display already at optimal rate ${activeMode.refreshRate}Hz for ${frameRate}fps")
|
||||
return@withContext Pair<Display.Mode?, DisplayModeSwitchResult?>(
|
||||
null,
|
||||
DisplayModeSwitchResult(activeMode)
|
||||
)
|
||||
}
|
||||
cleanupDisplayListener()
|
||||
recordOriginalMode(display)
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Switching display mode: ${activeMode.refreshRate}Hz -> ${modeBest.refreshRate}Hz " +
|
||||
"(video ${frameRate}fps)"
|
||||
)
|
||||
var completeImmediately = false
|
||||
if (onAfterSwitch != null) {
|
||||
pendingAfterSwitch = onAfterSwitch
|
||||
pendingMode = modeBest
|
||||
pendingDisplayId = display.displayId
|
||||
displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
|
||||
displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = Unit
|
||||
override fun onDisplayRemoved(displayId: Int) = Unit
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
if (displayId != pendingDisplayId) return
|
||||
completeSwitch("displayChanged")
|
||||
}
|
||||
}
|
||||
if (displayManager != null) {
|
||||
displayManager?.registerDisplayListener(
|
||||
displayListener,
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
scheduleSwitchTimeout()
|
||||
} else {
|
||||
completeImmediately = true
|
||||
}
|
||||
}
|
||||
|
||||
val layoutParams = window.attributes
|
||||
layoutParams.preferredDisplayModeId = modeBest.modeId
|
||||
window.attributes = layoutParams
|
||||
Pair(modeBest, null)
|
||||
} ?: return null
|
||||
onBeforeSwitch?.invoke()
|
||||
|
||||
val immediateResult = switchPlan.second
|
||||
if (immediateResult != null) return immediateResult
|
||||
val layoutParams = window.attributes
|
||||
layoutParams.preferredDisplayModeId = modeBest.modeId
|
||||
window.attributes = layoutParams
|
||||
|
||||
val expectedMode = switchPlan.first ?: return null
|
||||
var stablePolls = 0
|
||||
var lastMode: Display.Mode? = null
|
||||
val start = System.currentTimeMillis()
|
||||
|
||||
while (System.currentTimeMillis() - start < SWITCH_TIMEOUT_MS) {
|
||||
val mode = withContext(Dispatchers.Main) {
|
||||
activity.window?.decorView?.display?.mode
|
||||
} ?: break
|
||||
|
||||
lastMode = mode
|
||||
val modeStable =
|
||||
mode.modeId == expectedMode.modeId ||
|
||||
matchesTargetRefresh(mode.refreshRate, expectedMode.refreshRate)
|
||||
if (modeStable) {
|
||||
stablePolls += 1
|
||||
if (stablePolls >= SWITCH_REQUIRED_STABLE_POLLS) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Display mode switch stabilized at ${mode.refreshRate}Hz (modeId=${mode.modeId})"
|
||||
)
|
||||
return DisplayModeSwitchResult(mode)
|
||||
if (completeImmediately) {
|
||||
completeSwitch("noDisplayManager")
|
||||
}
|
||||
} else {
|
||||
stablePolls = 0
|
||||
Log.d(TAG, "Display already at optimal rate ${activeMode.refreshRate}Hz for ${frameRate}fps")
|
||||
}
|
||||
delay(SWITCH_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
val fallbackMode = lastMode ?: expectedMode
|
||||
Log.w(
|
||||
TAG,
|
||||
"Display mode polling timed out after ${SWITCH_TIMEOUT_MS}ms, using ${fallbackMode.refreshRate}Hz"
|
||||
)
|
||||
return DisplayModeSwitchResult(fallbackMode)
|
||||
switchNeeded
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to match frame rate", e)
|
||||
if (pendingAfterSwitch != null) {
|
||||
completeSwitch("error")
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanupDisplayListener() {
|
||||
// Kept for API compatibility with existing call sites.
|
||||
timeoutRunnable?.let { timeoutHandler?.removeCallbacks(it) }
|
||||
timeoutRunnable = null
|
||||
timeoutHandler = null
|
||||
|
||||
displayListener?.let { displayManager?.unregisterDisplayListener(it) }
|
||||
displayListener = null
|
||||
displayManager = null
|
||||
|
||||
pendingAfterSwitch = null
|
||||
pendingDisplayId = null
|
||||
pendingMode = null
|
||||
}
|
||||
|
||||
fun clearOriginalDisplayMode() {
|
||||
|
|
@ -337,93 +316,10 @@ object FrameRateUtils {
|
|||
return snapToStandardRate(measuredFps)
|
||||
}
|
||||
|
||||
private fun isValidVideoFrameRate(frameRate: Float): Boolean {
|
||||
return frameRate.isFinite() && frameRate in MIN_VALID_VIDEO_FPS..MAX_VALID_VIDEO_FPS
|
||||
}
|
||||
|
||||
fun detectFrameRateFromSource(
|
||||
context: Context,
|
||||
sourceUrl: String,
|
||||
headers: Map<String, String> = emptyMap()
|
||||
): FrameRateDetection? {
|
||||
detectFrameRateFromNextLib(context, sourceUrl, headers)?.let { return it }
|
||||
return detectFrameRateFromExtractor(context, sourceUrl, headers)
|
||||
}
|
||||
|
||||
fun detectFrameRateFromNextLib(
|
||||
context: Context,
|
||||
sourceUrl: String,
|
||||
headers: Map<String, String> = emptyMap()
|
||||
): FrameRateDetection? {
|
||||
return detectFrameRateWithNextLib(context, sourceUrl, headers)
|
||||
}
|
||||
|
||||
fun detectFrameRateFromExtractor(
|
||||
context: Context,
|
||||
sourceUrl: String,
|
||||
headers: Map<String, String> = emptyMap()
|
||||
): FrameRateDetection? {
|
||||
if (isResolveProxyUrl(sourceUrl)) {
|
||||
val embeddedResolveUrl = extractEmbeddedResolveUrl(sourceUrl)
|
||||
if (!embeddedResolveUrl.isNullOrBlank()) {
|
||||
detectFrameRateWithExtractor(context, embeddedResolveUrl, headers)?.let { return it }
|
||||
}
|
||||
}
|
||||
return detectFrameRateWithExtractor(context, sourceUrl, headers)
|
||||
}
|
||||
|
||||
private fun detectFrameRateWithNextLib(
|
||||
context: Context,
|
||||
sourceUrl: String,
|
||||
headers: Map<String, String>
|
||||
): FrameRateDetection? {
|
||||
if (!shouldUseNextLibProbe(sourceUrl, headers)) return null
|
||||
|
||||
val embeddedResolveUrl = extractEmbeddedResolveUrl(sourceUrl)
|
||||
val shouldPreferEmbedded = isResolveProxyUrl(sourceUrl)
|
||||
val candidates = buildList {
|
||||
if (shouldPreferEmbedded && !embeddedResolveUrl.isNullOrBlank() && embeddedResolveUrl != sourceUrl) {
|
||||
add(embeddedResolveUrl)
|
||||
add(sourceUrl)
|
||||
return@buildList
|
||||
}
|
||||
|
||||
add(sourceUrl)
|
||||
if (!embeddedResolveUrl.isNullOrBlank() && embeddedResolveUrl != sourceUrl) {
|
||||
add(embeddedResolveUrl)
|
||||
}
|
||||
}
|
||||
|
||||
candidates.forEach { candidateUrl ->
|
||||
var mediaInfo: MediaInfo? = null
|
||||
try {
|
||||
val uri = Uri.parse(candidateUrl)
|
||||
val builder = MediaInfoBuilder().from(context = context, uri = uri)
|
||||
mediaInfo = builder.build() ?: return@forEach
|
||||
|
||||
val video = mediaInfo.videoStream ?: return@forEach
|
||||
val measured = video.frameRate.toFloat()
|
||||
if (!isValidVideoFrameRate(measured)) return@forEach
|
||||
|
||||
return FrameRateDetection(
|
||||
raw = measured,
|
||||
snapped = snapToStandardRate(measured),
|
||||
videoWidth = video.frameWidth.takeIf { it > 0 },
|
||||
videoHeight = video.frameHeight.takeIf { it > 0 }
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "NextLib frame rate probe failed: ${e.message}")
|
||||
} finally {
|
||||
runCatching { mediaInfo?.release() }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun detectFrameRateWithExtractor(
|
||||
context: Context,
|
||||
sourceUrl: String,
|
||||
headers: Map<String, String>
|
||||
): FrameRateDetection? {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
|
|
@ -434,45 +330,19 @@ object FrameRateUtils {
|
|||
}
|
||||
|
||||
var videoTrackIndex = -1
|
||||
var videoFormat: android.media.MediaFormat? = null
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(android.media.MediaFormat.KEY_MIME)
|
||||
if (mime?.startsWith("video/") == true) {
|
||||
videoTrackIndex = i
|
||||
videoFormat = format
|
||||
break
|
||||
}
|
||||
}
|
||||
if (videoTrackIndex < 0) return null
|
||||
|
||||
val detectedVideoWidth = videoFormat
|
||||
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_WIDTH) }
|
||||
?.runCatching { getInteger(android.media.MediaFormat.KEY_WIDTH) }
|
||||
?.getOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
val detectedVideoHeight = videoFormat
|
||||
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_HEIGHT) }
|
||||
?.runCatching { getInteger(android.media.MediaFormat.KEY_HEIGHT) }
|
||||
?.getOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
val declaredFrameRate = videoFormat
|
||||
?.takeIf { it.containsKey(android.media.MediaFormat.KEY_FRAME_RATE) }
|
||||
?.runCatching { getFloat(android.media.MediaFormat.KEY_FRAME_RATE) }
|
||||
?.getOrNull()
|
||||
if (declaredFrameRate != null && isValidVideoFrameRate(declaredFrameRate)) {
|
||||
return FrameRateDetection(
|
||||
raw = declaredFrameRate,
|
||||
snapped = snapToStandardRate(declaredFrameRate),
|
||||
videoWidth = detectedVideoWidth,
|
||||
videoHeight = detectedVideoHeight
|
||||
)
|
||||
}
|
||||
|
||||
extractor.selectTrack(videoTrackIndex)
|
||||
val timestamps = ArrayList<Long>(400)
|
||||
val ignoreSamples = 3
|
||||
val ignoreSamples = 30
|
||||
val targetSamples = 350 + ignoreSamples
|
||||
|
||||
while (timestamps.size < targetSamples) {
|
||||
|
|
@ -490,19 +360,17 @@ object FrameRateUtils {
|
|||
}
|
||||
|
||||
val sampleCount = (timestamps.size - ignoreSamples - 1).coerceAtLeast(1)
|
||||
if (sampleCount < 30) return null
|
||||
if (sampleCount < 90) return null
|
||||
|
||||
val averageFrameDurationUs = totalFrameDurationUs.toFloat() / sampleCount.toFloat()
|
||||
if (averageFrameDurationUs <= 0f) return null
|
||||
|
||||
val measured = 1_000_000f / averageFrameDurationUs
|
||||
if (!isValidVideoFrameRate(measured)) return null
|
||||
if (measured < 10f || measured > 120f) return null
|
||||
|
||||
FrameRateDetection(
|
||||
raw = measured,
|
||||
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs),
|
||||
videoWidth = detectedVideoWidth,
|
||||
videoHeight = detectedVideoHeight
|
||||
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame rate probe failed: ${e.message}")
|
||||
|
|
@ -514,48 +382,4 @@ object FrameRateUtils {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldUseNextLibProbe(sourceUrl: String, headers: Map<String, String>): Boolean {
|
||||
if (sourceUrl.isBlank()) return false
|
||||
if (isLiveStreamUrl(sourceUrl)) return false
|
||||
if (isMkvSource(sourceUrl)) return true
|
||||
|
||||
val scheme = Uri.parse(sourceUrl).scheme?.lowercase(Locale.ROOT)
|
||||
return when (scheme) {
|
||||
in NEXTLIB_HTTP_SCHEMES -> true
|
||||
"file", "content" -> true
|
||||
null -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLiveStreamUrl(sourceUrl: String): Boolean {
|
||||
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
|
||||
return LIVE_STREAM_EXTENSIONS.any { ext -> normalized.endsWith(ext) }
|
||||
}
|
||||
|
||||
private fun isMkvSource(sourceUrl: String): Boolean {
|
||||
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
|
||||
return normalized.endsWith(MKV_EXTENSION)
|
||||
}
|
||||
|
||||
private fun isResolveProxyUrl(sourceUrl: String): Boolean {
|
||||
val normalized = sourceUrl.substringBefore('?').lowercase(Locale.ROOT)
|
||||
return "/resolve/" in normalized
|
||||
}
|
||||
|
||||
private fun extractEmbeddedResolveUrl(sourceUrl: String): String? {
|
||||
val marker = "/resolve/"
|
||||
val markerIndex = sourceUrl.indexOf(marker, ignoreCase = true)
|
||||
if (markerIndex < 0) return null
|
||||
|
||||
val afterResolve = sourceUrl.substring(markerIndex + marker.length)
|
||||
val nestedEncoded = afterResolve.substringAfter('/', missingDelimiterValue = "")
|
||||
.substringAfter('/', missingDelimiterValue = "")
|
||||
if (nestedEncoded.isBlank()) return null
|
||||
|
||||
return runCatching {
|
||||
URLDecoder.decode(nestedEncoded, StandardCharsets.UTF_8.name())
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -17,11 +17,6 @@ object StreamAutoPlaySelector {
|
|||
return orderedAddons + pluginEntries
|
||||
}
|
||||
|
||||
private fun isPlayable(stream: Stream): Boolean =
|
||||
stream.getStreamUrl() != null || stream.isTorrent()
|
||||
|
||||
|
||||
|
||||
fun selectAutoPlayStream(
|
||||
streams: List<Stream>,
|
||||
mode: StreamAutoPlayMode,
|
||||
|
|
@ -30,8 +25,7 @@ object StreamAutoPlaySelector {
|
|||
installedAddonNames: Set<String>,
|
||||
selectedAddons: Set<String>,
|
||||
selectedPlugins: Set<String>,
|
||||
preferredBingeGroup: String? = null,
|
||||
preferBingeGroupInSelection: Boolean = false
|
||||
preferredBingeGroup: String? = null
|
||||
): Stream? {
|
||||
if (streams.isEmpty()) return null
|
||||
|
||||
|
|
@ -52,22 +46,22 @@ object StreamAutoPlaySelector {
|
|||
if (mode == StreamAutoPlayMode.MANUAL) return null
|
||||
|
||||
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
|
||||
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
|
||||
if (targetBingeGroup.isNotEmpty()) {
|
||||
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
|
||||
stream.behaviorHints?.bingeGroup == targetBingeGroup && isPlayable(stream)
|
||||
stream.behaviorHints?.bingeGroup == targetBingeGroup && stream.getStreamUrl() != null
|
||||
}
|
||||
if (bingeGroupMatch != null) return bingeGroupMatch
|
||||
}
|
||||
|
||||
return when (mode) {
|
||||
StreamAutoPlayMode.MANUAL -> null
|
||||
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { isPlayable(it) }
|
||||
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.getStreamUrl() != null }
|
||||
StreamAutoPlayMode.REGEX_MATCH -> {
|
||||
val pattern = regexPattern.trim()
|
||||
if (pattern.isBlank()) return null
|
||||
|
||||
// Try to compile the user regex
|
||||
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull()
|
||||
if (userRegex == null) return null
|
||||
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull() ?: return null
|
||||
|
||||
// Auto-extract exclusion patterns from negative lookaheads
|
||||
val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern)
|
||||
|
|
@ -82,34 +76,31 @@ object StreamAutoPlaySelector {
|
|||
Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE)
|
||||
} else null
|
||||
|
||||
// 1. Build list of ALL regex‑matching streams
|
||||
val matchingStreams = candidateStreams.filter { stream ->
|
||||
if (!isPlayable(stream)) return@filter false
|
||||
|
||||
candidateStreams.firstOrNull { stream ->
|
||||
val url = stream.getStreamUrl() ?: return@firstOrNull false
|
||||
|
||||
val searchableText = buildString {
|
||||
append(stream.addonName).append(' ')
|
||||
append(stream.name.orEmpty()).append(' ')
|
||||
append(stream.title.orEmpty()).append(' ')
|
||||
append(stream.description.orEmpty()).append(' ')
|
||||
append(stream.getStreamUrl().orEmpty())
|
||||
if (stream.isTorrent()) append(' ').append(stream.infoHash.orEmpty())
|
||||
append(url)
|
||||
}
|
||||
|
||||
// Must match user include pattern
|
||||
if (!userRegex.containsMatchIn(searchableText)) return@firstOrNull false
|
||||
|
||||
// Must match include pattern
|
||||
if (!userRegex.containsMatchIn(searchableText)) return@filter false
|
||||
|
||||
// Must NOT match exclusion pattern
|
||||
// Must NOT match user exclusion pattern (if any)
|
||||
if (excludeRegex != null && excludeRegex.containsMatchIn(searchableText)) {
|
||||
return@filter false
|
||||
return@firstOrNull false
|
||||
}
|
||||
|
||||
true
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (matchingStreams.isEmpty()) return null
|
||||
matchingStreams.firstOrNull { isPlayable(it) }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,16 @@
|
|||
package com.nuvio.tv.core.plugin
|
||||
|
||||
import android.util.Log
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.nuvio.tv.core.plugin.cloudstream.toNuvioType
|
||||
import com.nuvio.tv.core.plugin.cloudstream.tvTypeFromString
|
||||
import com.nuvio.tv.core.plugin.cloudstream.ExternalExtensionLoader
|
||||
import com.nuvio.tv.core.plugin.cloudstream.ExternalExtensionRunner
|
||||
import com.nuvio.tv.core.plugin.cloudstream.ExternalRepoParser
|
||||
import com.nuvio.tv.data.local.PluginDataStore
|
||||
import com.nuvio.tv.domain.model.ExternalPluginEntry
|
||||
import com.nuvio.tv.domain.model.LocalScraperResult
|
||||
import com.nuvio.tv.domain.model.PluginManifest
|
||||
import com.nuvio.tv.domain.model.PluginRepository
|
||||
import com.nuvio.tv.domain.model.RemotePluginInfo
|
||||
import com.nuvio.tv.domain.model.RepositoryType
|
||||
import com.nuvio.tv.domain.model.ScraperInfo
|
||||
import com.nuvio.tv.domain.model.ScraperManifestInfo
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
|
@ -33,7 +22,6 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.security.MessageDigest
|
||||
|
|
@ -47,7 +35,6 @@ private const val TAG = "PluginManager"
|
|||
private const val MAX_CONCURRENT_SCRAPERS = 5
|
||||
private const val MAX_RESULT_ITEMS = 150
|
||||
private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L
|
||||
private const val SCRAPER_TIMEOUT_MS = 30_000L
|
||||
private const val MANIFEST_SUFFIX = "/manifest.json"
|
||||
|
||||
@Singleton
|
||||
|
|
@ -55,10 +42,7 @@ class PluginManager @Inject constructor(
|
|||
private val dataStore: PluginDataStore,
|
||||
private val runtime: PluginRuntime,
|
||||
private val pluginSyncService: com.nuvio.tv.core.sync.PluginSyncService,
|
||||
private val authManager: com.nuvio.tv.core.auth.AuthManager,
|
||||
private val externalRepoParser: ExternalRepoParser,
|
||||
private val externalExtensionLoader: ExternalExtensionLoader,
|
||||
private val externalExtensionRunner: ExternalExtensionRunner
|
||||
private val authManager: com.nuvio.tv.core.auth.AuthManager
|
||||
) {
|
||||
private val moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
|
|
@ -67,7 +51,6 @@ class PluginManager @Inject constructor(
|
|||
private val manifestAdapter = moshi.adapter(PluginManifest::class.java)
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
|
@ -82,96 +65,8 @@ class PluginManager @Inject constructor(
|
|||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize custom protocol schemes to https://.
|
||||
* External repos often use schemes like "cloudstreamrepo://" or "stremio://".
|
||||
*/
|
||||
private fun sanitizeScheme(url: String): String {
|
||||
val trimmed = url.trim()
|
||||
// Replace any non-http(s) scheme with https://
|
||||
val schemeEnd = trimmed.indexOf("://")
|
||||
if (schemeEnd > 0) {
|
||||
val scheme = trimmed.substring(0, schemeEnd).lowercase()
|
||||
if (scheme != "http" && scheme != "https") {
|
||||
return "https://${trimmed.substring(schemeEnd + 3)}"
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input looks like a short code rather than a URL.
|
||||
* Short codes are alphanumeric strings without slashes, dots (other than in a domain),
|
||||
* or protocol schemes — e.g. "cspr", "0094", "megarepo".
|
||||
*/
|
||||
private fun isShortCode(input: String): Boolean {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isEmpty()) return false
|
||||
// Has a scheme → not a short code
|
||||
if (trimmed.contains("://")) return false
|
||||
// Has path separators or dots → likely a URL or domain
|
||||
if (trimmed.contains("/") || trimmed.contains(".")) return false
|
||||
// Only alphanumeric + hyphens + underscores → short code
|
||||
return trimmed.all { it.isLetterOrDigit() || it == '-' || it == '_' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a short code by following the redirect from cutt.ly/{code}.
|
||||
* Returns the resolved URL or null if resolution fails.
|
||||
*/
|
||||
private fun resolveShortCode(code: String): String? {
|
||||
return try {
|
||||
// Use a client that does NOT follow redirects so we can read the Location header
|
||||
val noRedirectClient = httpClient.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("https://cutt.ly/$code")
|
||||
.header("User-Agent", "NuvioTV/1.0")
|
||||
.build()
|
||||
|
||||
noRedirectClient.newCall(request).execute().use { response ->
|
||||
if (response.code in 301..302) {
|
||||
val location = response.header("Location")
|
||||
if (!location.isNullOrBlank()) {
|
||||
Log.d(TAG, "Short code '$code' resolved to: $location")
|
||||
return sanitizeScheme(location)
|
||||
}
|
||||
}
|
||||
// Some shorteners return 200 with a meta refresh or JS redirect
|
||||
// Try following redirects as fallback
|
||||
Log.d(TAG, "Short code '$code' returned ${response.code}, trying with redirects")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to resolve short code '$code': ${e.message}")
|
||||
null
|
||||
} ?: try {
|
||||
// Fallback: follow redirects and see where we end up
|
||||
val request = Request.Builder()
|
||||
.url("https://cutt.ly/$code")
|
||||
.header("User-Agent", "NuvioTV/1.0")
|
||||
.build()
|
||||
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
val finalUrl = response.request.url.toString()
|
||||
if (finalUrl != "https://cutt.ly/$code" && response.isSuccessful) {
|
||||
Log.d(TAG, "Short code '$code' resolved via redirect chain to: $finalUrl")
|
||||
sanitizeScheme(finalUrl)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Fallback resolve for short code '$code' failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalizeManifestUrl(url: String): String {
|
||||
val trimmed = sanitizeScheme(url).trimEnd('/')
|
||||
val trimmed = url.trim().trimEnd('/')
|
||||
return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) {
|
||||
trimmed
|
||||
} else {
|
||||
|
|
@ -179,21 +74,7 @@ class PluginManager @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a URL for deduplication. For NuvioTV-style URLs (that don't end in .json),
|
||||
* appends /manifest.json. For URLs already ending in .json (external repos), keeps them as-is.
|
||||
*/
|
||||
private fun canonicalizeRepoUrl(url: String): String {
|
||||
val trimmed = sanitizeScheme(url).trimEnd('/')
|
||||
// If URL already ends with a .json file, it's likely an external repo URL — keep as-is
|
||||
if (trimmed.substringAfterLast("/").endsWith(".json", ignoreCase = true)) {
|
||||
return trimmed
|
||||
}
|
||||
// Otherwise canonicalize as NuvioTV manifest
|
||||
return canonicalizeManifestUrl(trimmed)
|
||||
}
|
||||
|
||||
private fun normalizeUrl(url: String): String = canonicalizeRepoUrl(url).lowercase()
|
||||
private fun normalizeUrl(url: String): String = canonicalizeManifestUrl(url).lowercase()
|
||||
|
||||
// Single-flight map to prevent duplicate scraper executions
|
||||
private val inFlightScrapers = ConcurrentHashMap<String, kotlinx.coroutines.Deferred<List<LocalScraperResult>>>()
|
||||
|
|
@ -216,30 +97,11 @@ class PluginManager @Inject constructor(
|
|||
|
||||
var isSyncingFromRemote = false
|
||||
|
||||
/** Prevents concurrent reconciliation from StartupSyncService and AccountViewModel */
|
||||
private val reconcileMutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var pendingPushAfterSync = false
|
||||
|
||||
/**
|
||||
* Call after setting isSyncingFromRemote = false to push any changes
|
||||
* that were made during reconciliation (e.g. repo removals).
|
||||
*/
|
||||
fun flushPendingSync() {
|
||||
if (pendingPushAfterSync) {
|
||||
pendingPushAfterSync = false
|
||||
Log.d(TAG, "flushPendingSync: firing deferred push")
|
||||
triggerRemoteSync()
|
||||
}
|
||||
}
|
||||
|
||||
private var syncJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
private fun triggerRemoteSync() {
|
||||
if (isSyncingFromRemote) {
|
||||
Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote), will push after sync")
|
||||
pendingPushAfterSync = true
|
||||
Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote)")
|
||||
return
|
||||
}
|
||||
if (!authManager.isAuthenticated) {
|
||||
|
|
@ -264,205 +126,73 @@ class PluginManager @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Add a new repository from manifest URL.
|
||||
* Auto-detects format: tries NuvioTV manifest first, then external repo format.
|
||||
* Add a new repository from manifest URL
|
||||
*/
|
||||
suspend fun addRepository(manifestUrl: String): Result<PluginRepository> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Resolve short codes (e.g. "cspr", "0094") via cutt.ly redirect
|
||||
val resolvedUrl = if (isShortCode(manifestUrl)) {
|
||||
Log.d(TAG, "Input looks like a short code: '$manifestUrl'")
|
||||
resolveShortCode(manifestUrl.trim())
|
||||
?: return@withContext Result.failure(
|
||||
Exception("Failed to resolve short code: $manifestUrl")
|
||||
)
|
||||
} else {
|
||||
sanitizeScheme(manifestUrl).trimEnd('/')
|
||||
}
|
||||
|
||||
val sanitizedUrl = resolvedUrl.trimEnd('/')
|
||||
val filename = sanitizedUrl.substringAfterLast("/")
|
||||
val isExplicitJsonFile = filename.endsWith(".json", ignoreCase = true)
|
||||
&& !filename.equals("manifest.json", ignoreCase = true)
|
||||
|
||||
// If the URL points to a specific .json file (not manifest.json),
|
||||
// try external format first to avoid a wasted 404 on the NuvioTV path.
|
||||
if (isExplicitJsonFile) {
|
||||
Log.d(TAG, "URL ends in .json — trying external format first: $sanitizedUrl")
|
||||
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
|
||||
if (externalResult != null) {
|
||||
return@withContext addExternalRepository(sanitizedUrl, externalResult)
|
||||
}
|
||||
}
|
||||
|
||||
// Try NuvioTV format (with canonicalized /manifest.json URL)
|
||||
val canonicalManifestUrl = canonicalizeManifestUrl(sanitizedUrl)
|
||||
Log.d(TAG, "Trying NuvioTV manifest: $canonicalManifestUrl")
|
||||
|
||||
val canonicalManifestUrl = canonicalizeManifestUrl(manifestUrl)
|
||||
Log.d(TAG, "Adding repository from: $canonicalManifestUrl")
|
||||
|
||||
// Fetch manifest
|
||||
val manifest = fetchManifest(canonicalManifestUrl)
|
||||
if (manifest != null) {
|
||||
return@withContext addNuvioRepository(canonicalManifestUrl, manifest)
|
||||
}
|
||||
?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
|
||||
|
||||
// Create repository
|
||||
val repo = PluginRepository(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = manifest.name,
|
||||
url = canonicalManifestUrl,
|
||||
enabled = true,
|
||||
lastUpdated = System.currentTimeMillis(),
|
||||
scraperCount = manifest.scrapers.size
|
||||
)
|
||||
|
||||
// Save repository
|
||||
dataStore.addRepository(repo)
|
||||
|
||||
// Download and save scrapers
|
||||
downloadScrapers(repo.id, canonicalManifestUrl, manifest.scrapers)
|
||||
|
||||
Log.d(TAG, "Repository added: ${repo.name} with ${manifest.scrapers.size} scrapers")
|
||||
triggerRemoteSync()
|
||||
Result.success(repo)
|
||||
|
||||
// If we haven't tried external format yet, try it now
|
||||
if (!isExplicitJsonFile) {
|
||||
Log.d(TAG, "NuvioTV manifest not found, trying external format: $sanitizedUrl")
|
||||
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
|
||||
if (externalResult != null) {
|
||||
return@withContext addExternalRepository(sanitizedUrl, externalResult)
|
||||
}
|
||||
}
|
||||
|
||||
Result.failure(Exception("Failed to parse repository: unrecognized format"))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to add repository: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a repository with a type hint from Supabase sync.
|
||||
* Skips wrong detection paths when the type is already known,
|
||||
* making reconciliation faster and more resilient to network issues.
|
||||
*/
|
||||
private suspend fun addRepositoryWithTypeHint(
|
||||
manifestUrl: String,
|
||||
typeHint: RepositoryType?
|
||||
): Result<PluginRepository> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val sanitizedUrl = sanitizeScheme(manifestUrl).trimEnd('/')
|
||||
|
||||
when (typeHint) {
|
||||
RepositoryType.EXTERNAL_DEX -> {
|
||||
Log.d(TAG, "addRepositoryWithTypeHint: EXTERNAL_DEX hint, trying external format: $sanitizedUrl")
|
||||
val externalResult = externalRepoParser.tryParse(sanitizedUrl)
|
||||
if (externalResult != null) {
|
||||
return@withContext addExternalRepository(sanitizedUrl, externalResult)
|
||||
}
|
||||
// Hint was wrong or parse failed — fall through to auto-detect
|
||||
Log.w(TAG, "addRepositoryWithTypeHint: EXTERNAL_DEX hint failed, falling back to auto-detect")
|
||||
}
|
||||
RepositoryType.NUVIO_JS -> {
|
||||
Log.d(TAG, "addRepositoryWithTypeHint: NUVIO_JS hint, trying manifest: $sanitizedUrl")
|
||||
val canonicalManifestUrl = canonicalizeManifestUrl(sanitizedUrl)
|
||||
val manifest = fetchManifest(canonicalManifestUrl)
|
||||
if (manifest != null) {
|
||||
return@withContext addNuvioRepository(canonicalManifestUrl, manifest)
|
||||
}
|
||||
Log.w(TAG, "addRepositoryWithTypeHint: NUVIO_JS hint failed, falling back to auto-detect")
|
||||
}
|
||||
null -> { /* No hint — use auto-detect */ }
|
||||
}
|
||||
|
||||
// Fall back to full auto-detection
|
||||
addRepository(sanitizedUrl)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to add repository with hint: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addNuvioRepository(
|
||||
canonicalManifestUrl: String,
|
||||
manifest: PluginManifest
|
||||
): Result<PluginRepository> {
|
||||
val repo = PluginRepository(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = manifest.name,
|
||||
url = canonicalManifestUrl,
|
||||
enabled = true,
|
||||
lastUpdated = System.currentTimeMillis(),
|
||||
scraperCount = manifest.scrapers.size,
|
||||
type = RepositoryType.NUVIO_JS
|
||||
)
|
||||
|
||||
dataStore.addRepository(repo)
|
||||
downloadJsScrapers(repo.id, canonicalManifestUrl, manifest.scrapers)
|
||||
|
||||
Log.d(TAG, "NuvioTV repository added: ${repo.name} with ${manifest.scrapers.size} scrapers")
|
||||
triggerRemoteSync()
|
||||
return Result.success(repo)
|
||||
}
|
||||
|
||||
private suspend fun addExternalRepository(
|
||||
repoUrl: String,
|
||||
parseResult: com.nuvio.tv.core.plugin.cloudstream.ExternalRepoParseResult
|
||||
): Result<PluginRepository> {
|
||||
// Prevent duplicate repos by URL
|
||||
val existingRepo = dataStore.repositories.first()
|
||||
.find { normalizeUrl(it.url) == normalizeUrl(repoUrl) }
|
||||
if (existingRepo != null) {
|
||||
Log.d(TAG, "External repository already exists: ${existingRepo.name} (${existingRepo.url})")
|
||||
return Result.success(existingRepo)
|
||||
}
|
||||
|
||||
val repo = PluginRepository(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = parseResult.name,
|
||||
url = repoUrl,
|
||||
description = parseResult.description,
|
||||
enabled = true,
|
||||
lastUpdated = System.currentTimeMillis(),
|
||||
scraperCount = parseResult.plugins.size,
|
||||
type = RepositoryType.EXTERNAL_DEX
|
||||
)
|
||||
|
||||
dataStore.addRepository(repo)
|
||||
downloadDexExtensions(repo.id, parseResult.plugins)
|
||||
|
||||
Log.d(TAG, "External repository added: ${repo.name} with ${parseResult.plugins.size} extensions")
|
||||
triggerRemoteSync()
|
||||
return Result.success(repo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a repository and its scrapers
|
||||
*/
|
||||
suspend fun removeRepository(repoId: String) {
|
||||
val scraperList = dataStore.scrapers.first()
|
||||
val repo = dataStore.repositories.first().find { it.id == repoId }
|
||||
|
||||
|
||||
// Remove all scrapers from this repo
|
||||
scraperList.filter { it.repositoryId == repoId }.forEach { scraper ->
|
||||
if (scraper.type == RepositoryType.EXTERNAL_DEX || repo?.type == RepositoryType.EXTERNAL_DEX) {
|
||||
externalExtensionLoader.deleteExtension(scraper.id)
|
||||
} else {
|
||||
dataStore.deleteScraperCode(scraper.id)
|
||||
}
|
||||
dataStore.deleteScraperCode(scraper.id)
|
||||
}
|
||||
|
||||
|
||||
// Remove scrapers from list
|
||||
val updatedScrapers = scraperList.filter { it.repositoryId != repoId }
|
||||
dataStore.saveScrapers(updatedScrapers)
|
||||
|
||||
|
||||
// Remove repository
|
||||
dataStore.removeRepository(repoId)
|
||||
|
||||
// Push synchronously when user-initiated (not during reconciliation)
|
||||
// to prevent the next sync pull from re-adding the removed repo
|
||||
if (!isSyncingFromRemote && authManager.isAuthenticated) {
|
||||
Log.d(TAG, "removeRepository: pushing removal to remote synchronously")
|
||||
pluginSyncService.pushToRemote()
|
||||
} else if (isSyncingFromRemote) {
|
||||
pendingPushAfterSync = true
|
||||
}
|
||||
triggerRemoteSync()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reconcile local plugin repos with the remote list from Supabase.
|
||||
* @param remotePlugins list of remote plugin info (URL + optional type hint)
|
||||
* @param removeMissingLocal if true, remove local repos not in the remote list
|
||||
*/
|
||||
suspend fun reconcileWithRemoteRepoUrls(
|
||||
remotePlugins: List<RemotePluginInfo>,
|
||||
remoteUrls: List<String>,
|
||||
removeMissingLocal: Boolean = true
|
||||
) = reconcileMutex.withLock {
|
||||
val normalizedRemote = remotePlugins
|
||||
.map { it.copy(url = canonicalizeRepoUrl(it.url)) }
|
||||
.filter { it.url.isNotEmpty() }
|
||||
.distinctBy { normalizeUrl(it.url) }
|
||||
val remoteUrlSet = normalizedRemote.map { normalizeUrl(it.url) }.toSet()
|
||||
) {
|
||||
val normalizedRemote = remoteUrls
|
||||
.map { canonicalizeManifestUrl(it) }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinctBy { normalizeUrl(it) }
|
||||
val remoteUrlSet = normalizedRemote.map { normalizeUrl(it) }.toSet()
|
||||
|
||||
val initialLocalRepos = dataStore.repositories.first()
|
||||
val initialLocalByNormalizedUrl = initialLocalRepos.associateBy { normalizeUrl(it.url) }
|
||||
|
|
@ -479,28 +209,19 @@ class PluginManager @Inject constructor(
|
|||
if (shouldRemoveMissingLocal) {
|
||||
initialLocalRepos
|
||||
.filter { normalizeUrl(it.url) !in remoteUrlSet }
|
||||
.forEach { repo ->
|
||||
Log.d(TAG, "reconcile: removing local repo not in remote: ${repo.name} (${repo.url})")
|
||||
removeRepository(repo.id)
|
||||
}
|
||||
.forEach { repo -> removeRepository(repo.id) }
|
||||
}
|
||||
|
||||
normalizedRemote.forEach { remotePlugin ->
|
||||
if (initialLocalByNormalizedUrl[normalizeUrl(remotePlugin.url)] == null) {
|
||||
val typeHint = remotePlugin.repoType?.let {
|
||||
try { RepositoryType.valueOf(it) } catch (_: Exception) { null }
|
||||
}
|
||||
val result = addRepositoryWithTypeHint(remotePlugin.url, typeHint)
|
||||
if (result.isFailure) {
|
||||
Log.e(TAG, "reconcile: failed to add repo ${remotePlugin.url}: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
normalizedRemote.forEach { remoteUrl ->
|
||||
if (initialLocalByNormalizedUrl[normalizeUrl(remoteUrl)] == null) {
|
||||
addRepository(remoteUrl)
|
||||
}
|
||||
}
|
||||
|
||||
val currentRepos = dataStore.repositories.first()
|
||||
val currentByNormalizedUrl = currentRepos.associateBy { normalizeUrl(it.url) }
|
||||
val remoteOrderedRepos = normalizedRemote
|
||||
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it.url)] }
|
||||
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it)] }
|
||||
val extras = currentRepos
|
||||
.filter { normalizeUrl(it.url) !in remoteUrlSet }
|
||||
|
||||
|
|
@ -509,18 +230,6 @@ class PluginManager @Inject constructor(
|
|||
dataStore.saveRepositories(reordered)
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience overload for plain URL lists (no type hints) */
|
||||
@JvmName("reconcileWithRemoteRepoUrlStrings")
|
||||
suspend fun reconcileWithRemoteRepoUrls(
|
||||
remoteUrls: List<String>,
|
||||
removeMissingLocal: Boolean = true
|
||||
) {
|
||||
reconcileWithRemoteRepoUrls(
|
||||
remotePlugins = remoteUrls.map { RemotePluginInfo(url = it) },
|
||||
removeMissingLocal = removeMissingLocal
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a repository - re-download manifest and scrapers
|
||||
|
|
@ -529,14 +238,10 @@ class PluginManager @Inject constructor(
|
|||
try {
|
||||
val repo = dataStore.repositories.first().find { it.id == repoId }
|
||||
?: return@withContext Result.failure(Exception("Repository not found"))
|
||||
|
||||
if (repo.type == RepositoryType.EXTERNAL_DEX) {
|
||||
return@withContext refreshExternalRepository(repo)
|
||||
}
|
||||
|
||||
|
||||
val manifest = fetchManifest(repo.url)
|
||||
?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
|
||||
|
||||
|
||||
// Update repository
|
||||
val updatedRepo = repo.copy(
|
||||
name = manifest.name,
|
||||
|
|
@ -544,36 +249,16 @@ class PluginManager @Inject constructor(
|
|||
scraperCount = manifest.scrapers.size
|
||||
)
|
||||
dataStore.updateRepository(updatedRepo)
|
||||
|
||||
|
||||
// Re-download scrapers
|
||||
downloadJsScrapers(repo.id, repo.url, manifest.scrapers)
|
||||
|
||||
downloadScrapers(repo.id, repo.url, manifest.scrapers)
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to refresh repository: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshExternalRepository(repo: PluginRepository): Result<Unit> {
|
||||
val parseResult = externalRepoParser.tryParse(repo.url)
|
||||
?: return Result.failure(Exception("Failed to parse external repository"))
|
||||
|
||||
// Evict stale class loaders for old scrapers
|
||||
val oldScrapers = dataStore.scrapers.first().filter { it.repositoryId == repo.id }
|
||||
oldScrapers.forEach { externalExtensionLoader.evictCache(it.id) }
|
||||
|
||||
val updatedRepo = repo.copy(
|
||||
name = parseResult.name,
|
||||
lastUpdated = System.currentTimeMillis(),
|
||||
scraperCount = parseResult.plugins.size
|
||||
)
|
||||
dataStore.updateRepository(updatedRepo)
|
||||
|
||||
downloadDexExtensions(repo.id, parseResult.plugins)
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle scraper enabled state
|
||||
|
|
@ -585,18 +270,7 @@ class PluginManager @Inject constructor(
|
|||
}
|
||||
dataStore.saveScrapers(updatedScrapers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all scrapers belonging to a repository
|
||||
*/
|
||||
suspend fun toggleAllScrapersForRepo(repoId: String, enabled: Boolean) {
|
||||
val scraperList = dataStore.scrapers.first()
|
||||
val updatedScrapers = scraperList.map { scraper ->
|
||||
if (scraper.repositoryId == repoId) scraper.copy(enabled = enabled) else scraper
|
||||
}
|
||||
dataStore.saveScrapers(updatedScrapers)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Toggle plugins globally enabled
|
||||
*/
|
||||
|
|
@ -625,20 +299,7 @@ class PluginManager @Inject constructor(
|
|||
}
|
||||
|
||||
Log.d(TAG, "Executing ${enabledScraperList.size} scrapers for $mediaType:$tmdbId")
|
||||
|
||||
// Preload all extractors from EXTERNAL_DEX repos before any scraper runs
|
||||
val dexScraperIds = enabledScraperList
|
||||
.filter { it.type == RepositoryType.EXTERNAL_DEX }
|
||||
.map { it.id }
|
||||
if (dexScraperIds.isNotEmpty()) {
|
||||
// Also load ALL dex scrapers from the same repos (not just enabled ones)
|
||||
// since extractors can live in any .cs3 file
|
||||
val allDexIds = dataStore.scrapers.first()
|
||||
.filter { it.type == RepositoryType.EXTERNAL_DEX }
|
||||
.map { it.id }
|
||||
externalExtensionLoader.ensureExtractorsLoaded(allDexIds)
|
||||
}
|
||||
|
||||
|
||||
val results = enabledScraperList.map { scraper ->
|
||||
async {
|
||||
executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode)
|
||||
|
|
@ -668,16 +329,7 @@ class PluginManager @Inject constructor(
|
|||
}
|
||||
|
||||
Log.d(TAG, "Streaming execution of ${enabledList.size} scrapers for $mediaType:$tmdbId")
|
||||
|
||||
// Preload all extractors from EXTERNAL_DEX repos before any scraper runs
|
||||
val dexScraperIds = enabledList.filter { it.type == RepositoryType.EXTERNAL_DEX }.map { it.id }
|
||||
if (dexScraperIds.isNotEmpty()) {
|
||||
val allDexIds = dataStore.scrapers.first()
|
||||
.filter { it.type == RepositoryType.EXTERNAL_DEX }
|
||||
.map { it.id }
|
||||
externalExtensionLoader.ensureExtractorsLoaded(allDexIds)
|
||||
}
|
||||
|
||||
|
||||
// Launch all scrapers concurrently within the channelFlow scope
|
||||
enabledList.forEach { scraper ->
|
||||
launch {
|
||||
|
|
@ -737,7 +389,7 @@ class PluginManager @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Execute a single scraper, dispatching by type.
|
||||
* Execute a single scraper
|
||||
*/
|
||||
suspend fun executeScraper(
|
||||
scraper: ScraperInfo,
|
||||
|
|
@ -745,19 +397,6 @@ class PluginManager @Inject constructor(
|
|||
mediaType: String,
|
||||
season: Int?,
|
||||
episode: Int?
|
||||
): List<LocalScraperResult> {
|
||||
return when (scraper.type) {
|
||||
RepositoryType.EXTERNAL_DEX -> executeExternalDexScraper(scraper, tmdbId, mediaType, season, episode)
|
||||
RepositoryType.NUVIO_JS -> executeJsScraper(scraper, tmdbId, mediaType, season, episode)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeJsScraper(
|
||||
scraper: ScraperInfo,
|
||||
tmdbId: String,
|
||||
mediaType: String,
|
||||
season: Int?,
|
||||
episode: Int?
|
||||
): List<LocalScraperResult> {
|
||||
return try {
|
||||
val code = dataStore.getScraperCode(scraper.id)
|
||||
|
|
@ -779,108 +418,45 @@ class PluginManager @Inject constructor(
|
|||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
|
||||
val settings = dataStore.getScraperSettings(scraper.id)
|
||||
|
||||
Log.d(TAG, "Executing scraper: ${scraper.name}")
|
||||
val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runtime.executePlugin(
|
||||
code = code,
|
||||
tmdbId = tmdbId,
|
||||
mediaType = mediaType,
|
||||
season = season,
|
||||
episode = episode,
|
||||
scraperId = scraper.id,
|
||||
scraperSettings = settings
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (results == null) {
|
||||
Log.w(TAG, "Scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val results = runtime.executePlugin(
|
||||
code = code,
|
||||
tmdbId = tmdbId,
|
||||
mediaType = mediaType,
|
||||
season = season,
|
||||
episode = episode,
|
||||
scraperId = scraper.id,
|
||||
scraperSettings = settings
|
||||
)
|
||||
|
||||
Log.d(TAG, "Scraper ${scraper.name} returned ${results.size} results")
|
||||
results.map { it.copy(provider = scraper.name) }
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to execute scraper ${scraper.name}: ${e.message}", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeExternalDexScraper(
|
||||
scraper: ScraperInfo,
|
||||
tmdbId: String,
|
||||
mediaType: String,
|
||||
season: Int?,
|
||||
episode: Int?
|
||||
): List<LocalScraperResult> {
|
||||
return try {
|
||||
Log.d(TAG, "Executing DEX scraper: ${scraper.name}")
|
||||
val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) {
|
||||
externalExtensionRunner.execute(scraper.id, tmdbId, mediaType, season, episode)
|
||||
}
|
||||
if (results == null) {
|
||||
Log.w(TAG, "DEX scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms")
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "DEX scraper ${scraper.name} returned ${results.size} results")
|
||||
results.map { it.copy(provider = scraper.name) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to execute DEX scraper ${scraper.name}: ${e.message}", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a scraper with sample data, returning results along with diagnostic steps.
|
||||
* Test a scraper with sample data
|
||||
*/
|
||||
suspend fun testScraper(scraperId: String): Result<Pair<List<LocalScraperResult>, TestDiagnostics>> {
|
||||
val diagnostics = TestDiagnostics()
|
||||
suspend fun testScraper(scraperId: String): Result<List<LocalScraperResult>> {
|
||||
val scraper = dataStore.scrapers.first().find { it.id == scraperId }
|
||||
if (scraper == null) {
|
||||
diagnostics.addStep("Scraper '$scraperId' not found in datastore")
|
||||
return Result.failure(Exception("Scraper not found"))
|
||||
}
|
||||
|
||||
diagnostics.addStep("Scraper: ${scraper.name} (type=${scraper.type})")
|
||||
|
||||
?: return Result.failure(Exception("Scraper not found"))
|
||||
|
||||
// Use a popular movie for testing (The Matrix - 603)
|
||||
val testTmdbId = "603"
|
||||
val testMediaType = if (scraper.supportsType("movie")) "movie" else "series"
|
||||
diagnostics.addStep("Test: TMDB $testTmdbId ($testMediaType)")
|
||||
|
||||
// Preload extractors from ALL .cs3 files in the same repo(s)
|
||||
if (scraper.type == RepositoryType.EXTERNAL_DEX) {
|
||||
val allDexIds = dataStore.scrapers.first()
|
||||
.filter { it.type == RepositoryType.EXTERNAL_DEX }
|
||||
.map { it.id }
|
||||
externalExtensionLoader.ensureExtractorsLoaded(allDexIds, diagnostics)
|
||||
}
|
||||
|
||||
val testSeason = if (testMediaType == "movie") null else 1
|
||||
val testEpisode = if (testMediaType == "movie") null else 1
|
||||
|
||||
|
||||
return try {
|
||||
val results = when (scraper.type) {
|
||||
RepositoryType.EXTERNAL_DEX -> {
|
||||
externalExtensionRunner.executeWithDiagnostics(
|
||||
scraper.id, testTmdbId, testMediaType, testSeason, testEpisode, diagnostics
|
||||
)
|
||||
}
|
||||
RepositoryType.NUVIO_JS -> {
|
||||
diagnostics.addStep("Executing JS scraper...")
|
||||
executeScraper(scraper, testTmdbId, testMediaType, testSeason, testEpisode)
|
||||
}
|
||||
}
|
||||
diagnostics.addStep("Result: ${results.size} streams")
|
||||
Result.success(results to diagnostics)
|
||||
val results = executeScraper(scraper, testTmdbId, testMediaType, 1, 1)
|
||||
Result.success(results)
|
||||
} catch (e: Exception) {
|
||||
diagnostics.addStep("Exception: ${e.javaClass.simpleName}: ${e.message}")
|
||||
Result.success(emptyList<LocalScraperResult>() to diagnostics)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -907,7 +483,7 @@ class PluginManager @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadJsScrapers(
|
||||
private suspend fun downloadScrapers(
|
||||
repoId: String,
|
||||
manifestUrl: String,
|
||||
scraperInfos: List<ScraperManifestInfo>
|
||||
|
|
@ -967,13 +543,6 @@ class PluginManager @Inject constructor(
|
|||
|
||||
// Create scraper info
|
||||
val scraperId = "$repoId:${info.id}"
|
||||
val existingScraper = existingScrapers.firstOrNull { it.id == scraperId }
|
||||
val defaultEnabled = info.enabled &&
|
||||
!PluginSafety.isVideoEasyScraper(
|
||||
scraperId = info.id,
|
||||
scraperName = info.name,
|
||||
filename = info.filename
|
||||
)
|
||||
val scraper = ScraperInfo(
|
||||
id = scraperId,
|
||||
repositoryId = repoId,
|
||||
|
|
@ -982,7 +551,7 @@ class PluginManager @Inject constructor(
|
|||
version = info.version,
|
||||
filename = info.filename,
|
||||
supportedTypes = info.supportedTypes,
|
||||
enabled = existingScraper?.enabled ?: defaultEnabled,
|
||||
enabled = true,
|
||||
manifestEnabled = info.enabled,
|
||||
logo = info.logo,
|
||||
contentLanguage = info.contentLanguage ?: emptyList(),
|
||||
|
|
@ -1005,77 +574,4 @@ class PluginManager @Inject constructor(
|
|||
|
||||
dataStore.saveScrapers(existingScrapers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download .cs3 DEX extensions in parallel and register them as scrapers.
|
||||
* Uses a semaphore to limit concurrent downloads and avoid overwhelming
|
||||
* the network. Scrapers are saved incrementally in batches.
|
||||
*/
|
||||
private suspend fun downloadDexExtensions(
|
||||
repoId: String,
|
||||
plugins: List<ExternalPluginEntry>
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val existingScrapers = dataStore.scrapers.first().toMutableList()
|
||||
val downloadSemaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
|
||||
val newScrapers = java.util.Collections.synchronizedList(mutableListOf<ScraperInfo>())
|
||||
|
||||
// Download all extensions in parallel with limited concurrency
|
||||
val jobs = plugins.map { plugin ->
|
||||
async {
|
||||
downloadSemaphore.withPermit {
|
||||
try {
|
||||
val scraperId = "$repoId:${plugin.internalName}"
|
||||
|
||||
val file = externalExtensionLoader.downloadExtension(scraperId, plugin.url)
|
||||
if (file == null) {
|
||||
Log.e(TAG, "Failed to download extension: ${plugin.name}")
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
val supportedTypes = plugin.tvTypes
|
||||
?.mapNotNull { tvTypeFromString(it) }
|
||||
?.map { it.toNuvioType() }
|
||||
?.distinct()
|
||||
?.ifEmpty { listOf("movie", "tv") }
|
||||
?: listOf("movie", "tv")
|
||||
|
||||
val scraper = ScraperInfo(
|
||||
id = scraperId,
|
||||
repositoryId = repoId,
|
||||
name = plugin.name,
|
||||
description = plugin.description ?: "",
|
||||
version = plugin.version.toString(),
|
||||
filename = plugin.url,
|
||||
supportedTypes = supportedTypes,
|
||||
enabled = true,
|
||||
manifestEnabled = plugin.status == 1,
|
||||
logo = plugin.iconUrl,
|
||||
contentLanguage = emptyList(),
|
||||
formats = null,
|
||||
type = RepositoryType.EXTERNAL_DEX
|
||||
)
|
||||
|
||||
newScrapers.add(scraper)
|
||||
Log.d(TAG, "Downloaded DEX extension: ${plugin.name} (${file.length()} bytes)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error downloading extension ${plugin.name}: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jobs.awaitAll()
|
||||
|
||||
// Merge new scrapers into existing list
|
||||
val newScraperIds = newScrapers.map { it.id }.toSet()
|
||||
existingScrapers.removeAll { it.id in newScraperIds }
|
||||
existingScrapers.addAll(newScrapers)
|
||||
dataStore.saveScrapers(existingScrapers)
|
||||
|
||||
Log.d(TAG, "Downloaded ${newScrapers.size}/${plugins.size} extensions for repo $repoId")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_PARALLEL_DOWNLOADS = 10
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
package com.nuvio.tv.core.plugin
|
||||
|
||||
import android.util.Log
|
||||
import com.dokar.quickjs.binding.asyncFunction
|
||||
import com.dokar.quickjs.binding.define
|
||||
import com.dokar.quickjs.binding.function
|
||||
import com.dokar.quickjs.quickJs
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.nuvio.tv.BuildConfig
|
||||
import com.nuvio.tv.domain.model.LocalScraperResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -39,10 +39,7 @@ import javax.inject.Singleton
|
|||
|
||||
private const val TAG = "PluginRuntime"
|
||||
private const val PLUGIN_TIMEOUT_MS = 60_000L
|
||||
private const val MAX_FETCH_RESPONSE_BYTES = 256 * 1024
|
||||
private const val MAX_FETCH_BODY_CHARS = 256 * 1024
|
||||
private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024
|
||||
private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]"
|
||||
private const val MAX_FETCH_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
@Singleton
|
||||
class PluginRuntime @Inject constructor() {
|
||||
|
|
@ -50,7 +47,6 @@ class PluginRuntime @Inject constructor() {
|
|||
private val gson: Gson = GsonBuilder().create()
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
|
|
@ -59,6 +55,10 @@ class PluginRuntime @Inject constructor() {
|
|||
.proxy(java.net.Proxy.NO_PROXY)
|
||||
.build()
|
||||
|
||||
// Store parsed documents for cheerio
|
||||
private val documentCache = ConcurrentHashMap<String, Document>()
|
||||
private val elementCache = ConcurrentHashMap<String, Element>()
|
||||
|
||||
// Pre-compiled regex for :contains() selector conversion
|
||||
private val containsRegex = Regex(""":contains\(["']([^"']+)["']\)""")
|
||||
|
||||
|
|
@ -144,99 +144,87 @@ class PluginRuntime @Inject constructor() {
|
|||
scraperId: String,
|
||||
scraperSettings: Map<String, Any>
|
||||
): List<LocalScraperResult> {
|
||||
val documentCache = ConcurrentHashMap<String, Document>()
|
||||
val elementCache = ConcurrentHashMap<String, Element>()
|
||||
// Clear caches before execution
|
||||
documentCache.clear()
|
||||
elementCache.clear()
|
||||
val inFlightCalls = ConcurrentHashMap.newKeySet<Call>()
|
||||
|
||||
var resultJson = "[]"
|
||||
|
||||
try {
|
||||
quickJs(Dispatchers.IO) {
|
||||
// Define console object - must return null to avoid quickjs conversion issues
|
||||
define("console") {
|
||||
function("log") { args ->
|
||||
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
function("error") { args ->
|
||||
Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
function("warn") { args ->
|
||||
Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
function("info") { args ->
|
||||
Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
function("debug") { args ->
|
||||
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
// Define console object - must return null to avoid quickjs conversion issues
|
||||
define("console") {
|
||||
function("log") { args ->
|
||||
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
function("error") { args ->
|
||||
Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
function("warn") { args ->
|
||||
Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
function("info") { args ->
|
||||
Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
function("debug") { args ->
|
||||
Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
function("__native_fetch") { args ->
|
||||
val url = args.getOrNull(0)?.toString() ?: ""
|
||||
val method = args.getOrNull(1)?.toString() ?: "GET"
|
||||
val headersJson = args.getOrNull(2)?.toString() ?: "{}"
|
||||
val body = args.getOrNull(3)?.toString() ?: ""
|
||||
try {
|
||||
performNativeFetch(url, method, headersJson, body, inFlightCalls)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Async fetch bridge error for $method $url: ${t.message}")
|
||||
gson.toJson(
|
||||
mapOf(
|
||||
"ok" to false,
|
||||
"status" to 0,
|
||||
"statusText" to (t.message ?: "Fetch failed"),
|
||||
"url" to url,
|
||||
"body" to "",
|
||||
"headers" to emptyMap<String, String>()
|
||||
)
|
||||
)
|
||||
// Define native fetch function (async)
|
||||
asyncFunction("__native_fetch") { args ->
|
||||
val url = args.getOrNull(0)?.toString() ?: ""
|
||||
val method = args.getOrNull(1)?.toString() ?: "GET"
|
||||
val headersJson = args.getOrNull(2)?.toString() ?: "{}"
|
||||
val body = args.getOrNull(3)?.toString() ?: ""
|
||||
performNativeFetch(url, method, headersJson, body, inFlightCalls)
|
||||
}
|
||||
|
||||
// Define URL parser
|
||||
function("__parse_url") { args ->
|
||||
val urlString = args.getOrNull(0)?.toString() ?: ""
|
||||
parseUrl(urlString)
|
||||
}
|
||||
|
||||
// Define cheerio load function
|
||||
function("__cheerio_load") { args ->
|
||||
val html = args.getOrNull(0)?.toString() ?: ""
|
||||
val docId = UUID.randomUUID().toString()
|
||||
val doc = Jsoup.parse(html)
|
||||
documentCache[docId] = doc
|
||||
docId
|
||||
}
|
||||
|
||||
// Define cheerio select function
|
||||
function("__cheerio_select") { args ->
|
||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
||||
var selector = args.getOrNull(1)?.toString() ?: ""
|
||||
val doc = documentCache[docId] ?: return@function "[]"
|
||||
try {
|
||||
// Convert cheerio :contains("text") to jsoup :contains(text)
|
||||
selector = selector.replace(containsRegex, ":contains($1)")
|
||||
val elements = if (selector.isEmpty()) {
|
||||
Elements()
|
||||
} else {
|
||||
doc.select(selector)
|
||||
}
|
||||
}
|
||||
|
||||
// Define URL parser
|
||||
function("__parse_url") { args ->
|
||||
val urlString = args.getOrNull(0)?.toString() ?: ""
|
||||
parseUrl(urlString)
|
||||
}
|
||||
|
||||
// Define cheerio load function
|
||||
function("__cheerio_load") { args ->
|
||||
val html = args.getOrNull(0)?.toString() ?: ""
|
||||
val docId = UUID.randomUUID().toString()
|
||||
val doc = Jsoup.parse(html)
|
||||
documentCache[docId] = doc
|
||||
docId
|
||||
}
|
||||
|
||||
// Define cheerio select function
|
||||
function("__cheerio_select") { args ->
|
||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
||||
var selector = args.getOrNull(1)?.toString() ?: ""
|
||||
val doc = documentCache[docId] ?: return@function "[]"
|
||||
try {
|
||||
// Convert cheerio :contains("text") to jsoup :contains(text)
|
||||
selector = selector.replace(containsRegex, ":contains($1)")
|
||||
val elements = if (selector.isEmpty()) {
|
||||
Elements()
|
||||
} else {
|
||||
doc.select(selector)
|
||||
}
|
||||
val ids = elements.mapIndexed { index, el ->
|
||||
val elId = "$docId:$index:${el.hashCode()}"
|
||||
elementCache[elId] = el
|
||||
elId
|
||||
}
|
||||
// Use simple JSON array construction to avoid Gson issues
|
||||
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
|
||||
} catch (e: Exception) {
|
||||
"[]"
|
||||
val ids = elements.mapIndexed { index, el ->
|
||||
val elId = "$docId:$index:${el.hashCode()}"
|
||||
elementCache[elId] = el
|
||||
elId
|
||||
}
|
||||
// Use simple JSON array construction to avoid Gson issues
|
||||
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
|
||||
} catch (e: Exception) {
|
||||
"[]"
|
||||
}
|
||||
}
|
||||
|
||||
// Define cheerio find function
|
||||
function("__cheerio_find") { args ->
|
||||
|
|
@ -372,7 +360,7 @@ class PluginRuntime @Inject constructor() {
|
|||
})();
|
||||
""".trimIndent()
|
||||
|
||||
evaluate<Any?>(callCode)
|
||||
evaluate<Any?>(callCode)
|
||||
}
|
||||
|
||||
return parseJsonResults(resultJson)
|
||||
|
|
@ -451,10 +439,10 @@ class PluginRuntime @Inject constructor() {
|
|||
response.use { httpResponse ->
|
||||
val bodyContentType = httpResponse.body?.contentType()
|
||||
val contentEncoding = httpResponse.header("Content-Encoding")?.lowercase()?.trim()
|
||||
val decodedRead = try {
|
||||
val decodedBytes = try {
|
||||
val stream = httpResponse.body?.byteStream()
|
||||
if (stream == null) {
|
||||
BoundedReadResult(ByteArray(0), false)
|
||||
ByteArray(0)
|
||||
} else {
|
||||
val decodeStream: InputStream = when (contentEncoding) {
|
||||
"gzip" -> GZIPInputStream(stream)
|
||||
|
|
@ -467,14 +455,18 @@ class PluginRuntime @Inject constructor() {
|
|||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to read/decode response body for $url: ${e.message}")
|
||||
BoundedReadResult(ByteArray(0), false)
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
val charset = bodyContentType?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
|
||||
val responseBody = decodeBodyToSafeString(decodedRead.bytes, charset)
|
||||
val responseBody = try {
|
||||
String(decodedBytes, charset)
|
||||
} catch (e: Exception) {
|
||||
String(decodedBytes, Charsets.UTF_8)
|
||||
}
|
||||
val responseHeaders = mutableMapOf<String, String>()
|
||||
httpResponse.headers.forEach { (name, value) ->
|
||||
responseHeaders[name.lowercase()] = truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS)
|
||||
responseHeaders[name.lowercase()] = value
|
||||
}
|
||||
|
||||
val result = mapOf(
|
||||
|
|
@ -483,8 +475,7 @@ class PluginRuntime @Inject constructor() {
|
|||
"statusText" to httpResponse.message,
|
||||
"url" to httpResponse.request.url.toString(),
|
||||
"body" to responseBody,
|
||||
"headers" to responseHeaders,
|
||||
"truncated" to decodedRead.truncated
|
||||
"headers" to responseHeaders
|
||||
)
|
||||
|
||||
Log.d(TAG, "Fetch result: ${httpResponse.code} ${httpResponse.message} url=$url bodyLen=${responseBody.length} bodyPreview=${responseBody.take(300)}")
|
||||
|
|
@ -506,32 +497,10 @@ class PluginRuntime @Inject constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
private data class BoundedReadResult(
|
||||
val bytes: ByteArray,
|
||||
val truncated: Boolean
|
||||
)
|
||||
|
||||
private fun truncateString(value: String, maxChars: Int): String {
|
||||
if (value.length <= maxChars) return value
|
||||
val end = maxChars - FETCH_TRUNCATION_SUFFIX.length
|
||||
if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars)
|
||||
return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX
|
||||
}
|
||||
|
||||
private fun decodeBodyToSafeString(bytes: ByteArray, charset: java.nio.charset.Charset): String {
|
||||
val decoded = try {
|
||||
String(bytes, charset)
|
||||
} catch (e: Exception) {
|
||||
String(bytes, Charsets.UTF_8)
|
||||
}
|
||||
return truncateString(decoded, MAX_FETCH_BODY_CHARS)
|
||||
}
|
||||
|
||||
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): BoundedReadResult {
|
||||
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): ByteArray {
|
||||
val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024))
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var remaining = maxBytes
|
||||
var truncated = false
|
||||
|
||||
while (remaining > 0) {
|
||||
val read = stream.read(buffer, 0, minOf(buffer.size, remaining))
|
||||
|
|
@ -539,10 +508,7 @@ class PluginRuntime @Inject constructor() {
|
|||
out.write(buffer, 0, read)
|
||||
remaining -= read
|
||||
}
|
||||
if (remaining == 0) {
|
||||
truncated = stream.read() != -1
|
||||
}
|
||||
return BoundedReadResult(out.toByteArray(), truncated)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun parseUrl(urlString: String): String {
|
||||
|
|
@ -576,16 +542,7 @@ class PluginRuntime @Inject constructor() {
|
|||
globalThis.SCRAPER_ID = "$scraperId";
|
||||
globalThis.SCRAPER_SETTINGS = $settingsJson;
|
||||
if (typeof TMDB_API_KEY === 'undefined') {
|
||||
globalThis.TMDB_API_KEY = "${BuildConfig.TMDB_API_KEY}";
|
||||
}
|
||||
if (typeof globalThis.global === 'undefined') {
|
||||
globalThis.global = globalThis;
|
||||
}
|
||||
if (typeof globalThis.window === 'undefined') {
|
||||
globalThis.window = globalThis;
|
||||
}
|
||||
if (typeof globalThis.self === 'undefined') {
|
||||
globalThis.self = globalThis;
|
||||
globalThis.TMDB_API_KEY = "1865f43a0549ca50d341dd9ab8b29f49";
|
||||
}
|
||||
|
||||
// Fetch implementation (async)
|
||||
|
|
@ -607,7 +564,7 @@ class PluginRuntime @Inject constructor() {
|
|||
headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
|
||||
}
|
||||
|
||||
var result = __native_fetch(url, method, JSON.stringify(headers), body);
|
||||
var result = await __native_fetch(url, method, JSON.stringify(headers), body);
|
||||
var parsed = JSON.parse(result);
|
||||
|
||||
if (signal && signal.aborted) {
|
||||
|
|
@ -630,15 +587,10 @@ class PluginRuntime @Inject constructor() {
|
|||
return Promise.resolve(parsed.body);
|
||||
},
|
||||
json: function() {
|
||||
|
||||
try {
|
||||
if (parsed.body === null || parsed.body === undefined || parsed.body === '') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(JSON.parse(parsed.body));
|
||||
} catch (e) {
|
||||
console.error('fetch.json parse error:', e && e.message ? e.message : e);
|
||||
return Promise.resolve(null);
|
||||
return Promise.reject(new Error('JSON parse error: ' + e.message));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -48,8 +48,7 @@ class ProfileManager @Inject constructor(
|
|||
name: String,
|
||||
avatarColorHex: String,
|
||||
usesPrimaryAddons: Boolean = false,
|
||||
usesPrimaryPlugins: Boolean = false,
|
||||
avatarId: String? = null
|
||||
usesPrimaryPlugins: Boolean = false
|
||||
): Boolean {
|
||||
val current = profiles.value
|
||||
if (current.size >= 4) return false
|
||||
|
|
@ -62,10 +61,8 @@ class ProfileManager @Inject constructor(
|
|||
name = name.trim().ifEmpty { "Profile $nextId" },
|
||||
avatarColorHex = avatarColorHex,
|
||||
usesPrimaryAddons = usesPrimaryAddons,
|
||||
usesPrimaryPlugins = usesPrimaryPlugins,
|
||||
avatarId = avatarId
|
||||
usesPrimaryPlugins = usesPrimaryPlugins
|
||||
)
|
||||
factory.markProfileCreated(nextId)
|
||||
profileDataStore.upsertProfile(profile)
|
||||
return true
|
||||
}
|
||||
|
|
@ -73,7 +70,7 @@ class ProfileManager @Inject constructor(
|
|||
suspend fun deleteProfile(id: Int): Boolean {
|
||||
if (id == 1) return false
|
||||
if (profiles.value.none { it.id == id }) return false
|
||||
deleteProfileDataAsync(id)
|
||||
deleteProfileData(id)
|
||||
profileDataStore.deleteProfile(id)
|
||||
return true
|
||||
}
|
||||
|
|
@ -84,7 +81,7 @@ class ProfileManager @Inject constructor(
|
|||
return true
|
||||
}
|
||||
|
||||
private suspend fun deleteProfileDataAsync(profileId: Int) {
|
||||
private fun deleteProfileData(profileId: Int) {
|
||||
if (profileId == 1) return
|
||||
|
||||
factory.clearProfile(profileId)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.nuvio.tv.core.server
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
|
|
@ -9,28 +8,12 @@ import java.util.UUID
|
|||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class AddonConfigServer(
|
||||
private val context: Context,
|
||||
private val webConfigMode: WebConfigMode,
|
||||
private val currentPageStateProvider: () -> PageState,
|
||||
private val onChangeProposed: (PendingAddonChange) -> Unit,
|
||||
private val logoProvider: (() -> ByteArray?)? = null,
|
||||
port: Int = 8080
|
||||
) : NanoHTTPD(port) {
|
||||
|
||||
enum class WebConfigMode(
|
||||
val allowAddonManagement: Boolean,
|
||||
val allowCatalogManagement: Boolean
|
||||
) {
|
||||
FULL(
|
||||
allowAddonManagement = true,
|
||||
allowCatalogManagement = true
|
||||
),
|
||||
COLLECTIONS_ONLY(
|
||||
allowAddonManagement = false,
|
||||
allowCatalogManagement = false
|
||||
)
|
||||
}
|
||||
|
||||
data class AddonInfo(
|
||||
val url: String,
|
||||
val name: String,
|
||||
|
|
@ -46,41 +29,9 @@ class AddonConfigServer(
|
|||
val isDisabled: Boolean
|
||||
)
|
||||
|
||||
data class CollectionInfo(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val backdropImageUrl: String? = null,
|
||||
val pinToTop: Boolean = false,
|
||||
val focusGlowEnabled: Boolean = true,
|
||||
val viewMode: String = "TABBED_GRID",
|
||||
val showAllTab: Boolean = true,
|
||||
val folders: List<FolderInfo>
|
||||
)
|
||||
|
||||
data class FolderInfo(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val coverImageUrl: String?,
|
||||
val focusGifUrl: String?,
|
||||
val focusGifEnabled: Boolean = true,
|
||||
val coverEmoji: String?,
|
||||
val tileShape: String,
|
||||
val hideTitle: Boolean,
|
||||
val catalogSources: List<CatalogSourceInfo>
|
||||
)
|
||||
|
||||
data class CatalogSourceInfo(
|
||||
val addonId: String,
|
||||
val type: String,
|
||||
val catalogId: String,
|
||||
val genre: String? = null
|
||||
)
|
||||
|
||||
data class PageState(
|
||||
val addons: List<AddonInfo>,
|
||||
val catalogs: List<CatalogInfo>,
|
||||
val collections: List<CollectionInfo> = emptyList(),
|
||||
val disabledCollectionKeys: List<String> = emptyList()
|
||||
val catalogs: List<CatalogInfo>
|
||||
)
|
||||
|
||||
data class PendingAddonChange(
|
||||
|
|
@ -88,8 +39,6 @@ class AddonConfigServer(
|
|||
val proposedUrls: List<String>,
|
||||
val proposedCatalogOrderKeys: List<String> = emptyList(),
|
||||
val proposedDisabledCatalogKeys: List<String> = emptyList(),
|
||||
val proposedCollectionsJson: String? = null,
|
||||
val proposedDisabledCollectionKeys: List<String> = emptyList(),
|
||||
var status: ChangeStatus = ChangeStatus.PENDING
|
||||
)
|
||||
|
||||
|
|
@ -116,18 +65,13 @@ class AddonConfigServer(
|
|||
method == Method.GET && uri == "/api/state" -> servePageState()
|
||||
method == Method.GET && uri == "/api/addons" -> serveAddonList()
|
||||
method == Method.POST && uri == "/api/addons" -> handleAddonUpdate(session)
|
||||
method == Method.GET && uri == "/api/collections" -> serveCollections()
|
||||
method == Method.GET && uri.startsWith("/api/status/") -> serveChangeStatus(uri)
|
||||
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun serveWebPage(): Response {
|
||||
return newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/html; charset=utf-8",
|
||||
AddonWebPage.getHtml(context, webConfigMode)
|
||||
)
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html", AddonWebPage.getHtml())
|
||||
}
|
||||
|
||||
private fun serveLogo(): Response {
|
||||
|
|
@ -144,22 +88,16 @@ class AddonConfigServer(
|
|||
}
|
||||
}
|
||||
|
||||
private fun serveCollections(): Response {
|
||||
val collections = currentPageStateProvider().collections
|
||||
val json = gson.toJson(collections)
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
|
||||
}
|
||||
|
||||
private fun serveAddonList(): Response {
|
||||
val addons = currentPageStateProvider().addons
|
||||
val json = gson.toJson(addons)
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json", json)
|
||||
}
|
||||
|
||||
private fun servePageState(): Response {
|
||||
val state = currentPageStateProvider()
|
||||
val json = gson.toJson(state)
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", json)
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json", json)
|
||||
}
|
||||
|
||||
private fun handleAddonUpdate(session: IHTTPSession): Response {
|
||||
|
|
@ -178,25 +116,16 @@ class AddonConfigServer(
|
|||
val urls = parseStringList(parsed["urls"])
|
||||
val catalogOrderKeys = parseStringList(parsed["catalogOrderKeys"])
|
||||
val disabledCatalogKeys = parseStringList(parsed["disabledCatalogKeys"])
|
||||
val collectionsRaw = parsed["collections"]
|
||||
val collectionsJson = if (collectionsRaw != null) gson.toJson(collectionsRaw) else null
|
||||
val disabledCollectionKeys = parseStringList(parsed["disabledCollectionKeys"])
|
||||
sanitizePendingAddonChange(
|
||||
mode = webConfigMode,
|
||||
proposedChange = PendingAddonChange(
|
||||
proposedUrls = urls,
|
||||
proposedCatalogOrderKeys = catalogOrderKeys,
|
||||
proposedDisabledCatalogKeys = disabledCatalogKeys,
|
||||
proposedCollectionsJson = collectionsJson,
|
||||
proposedDisabledCollectionKeys = disabledCollectionKeys
|
||||
),
|
||||
currentState = currentPageStateProvider()
|
||||
PendingAddonChange(
|
||||
proposedUrls = urls,
|
||||
proposedCatalogOrderKeys = catalogOrderKeys,
|
||||
proposedDisabledCatalogKeys = disabledCatalogKeys
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
val error = mapOf("error" to "Invalid request body")
|
||||
return newFixedLengthResponse(
|
||||
Response.Status.BAD_REQUEST,
|
||||
"application/json; charset=utf-8",
|
||||
"application/json",
|
||||
gson.toJson(error)
|
||||
)
|
||||
}
|
||||
|
|
@ -205,7 +134,7 @@ class AddonConfigServer(
|
|||
onChangeProposed(change)
|
||||
|
||||
val response = mapOf("status" to "pending_confirmation", "id" to change.id)
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", gson.toJson(response))
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json", gson.toJson(response))
|
||||
}
|
||||
|
||||
private fun serveChangeStatus(uri: String): Response {
|
||||
|
|
@ -213,7 +142,7 @@ class AddonConfigServer(
|
|||
val change = pendingChanges[id]
|
||||
val status = change?.status?.name?.lowercase() ?: "not_found"
|
||||
val response = mapOf("status" to status)
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", gson.toJson(response))
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/json", gson.toJson(response))
|
||||
}
|
||||
|
||||
private fun parseStringList(rawValue: Any?): List<String> {
|
||||
|
|
@ -227,8 +156,6 @@ class AddonConfigServer(
|
|||
|
||||
companion object {
|
||||
fun startOnAvailablePort(
|
||||
context: Context,
|
||||
webConfigMode: WebConfigMode = WebConfigMode.FULL,
|
||||
currentPageStateProvider: () -> PageState,
|
||||
onChangeProposed: (PendingAddonChange) -> Unit,
|
||||
logoProvider: (() -> ByteArray?)? = null,
|
||||
|
|
@ -237,14 +164,7 @@ class AddonConfigServer(
|
|||
): AddonConfigServer? {
|
||||
for (port in startPort until startPort + maxAttempts) {
|
||||
try {
|
||||
val server = AddonConfigServer(
|
||||
context = context,
|
||||
webConfigMode = webConfigMode,
|
||||
currentPageStateProvider = currentPageStateProvider,
|
||||
onChangeProposed = onChangeProposed,
|
||||
logoProvider = logoProvider,
|
||||
port = port
|
||||
)
|
||||
val server = AddonConfigServer(currentPageStateProvider, onChangeProposed, logoProvider, port)
|
||||
server.start(SOCKET_READ_TIMEOUT, false)
|
||||
return server
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -255,33 +175,3 @@ class AddonConfigServer(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sanitizePendingAddonChange(
|
||||
mode: AddonConfigServer.WebConfigMode,
|
||||
proposedChange: AddonConfigServer.PendingAddonChange,
|
||||
currentState: AddonConfigServer.PageState
|
||||
): AddonConfigServer.PendingAddonChange {
|
||||
if (mode.allowAddonManagement && mode.allowCatalogManagement) {
|
||||
return proposedChange
|
||||
}
|
||||
|
||||
return proposedChange.copy(
|
||||
proposedUrls = if (mode.allowAddonManagement) {
|
||||
proposedChange.proposedUrls
|
||||
} else {
|
||||
currentState.addons.map { it.url }
|
||||
},
|
||||
proposedCatalogOrderKeys = if (mode.allowCatalogManagement) {
|
||||
proposedChange.proposedCatalogOrderKeys
|
||||
} else {
|
||||
currentState.catalogs.map { it.key }
|
||||
},
|
||||
proposedDisabledCatalogKeys = if (mode.allowCatalogManagement) {
|
||||
proposedChange.proposedDisabledCatalogKeys
|
||||
} else {
|
||||
currentState.catalogs
|
||||
.filter { it.isDisabled }
|
||||
.map { it.disableKey }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,5 @@
|
|||
package com.nuvio.tv.core.server
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
|
|
@ -9,7 +8,6 @@ import java.util.UUID
|
|||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class RepositoryConfigServer(
|
||||
private val context: Context,
|
||||
private val currentRepositoriesProvider: () -> List<RepositoryInfo>,
|
||||
private val onChangeProposed: (PendingRepoChange) -> Unit,
|
||||
private val logoProvider: (() -> ByteArray?)? = null,
|
||||
|
|
@ -56,7 +54,7 @@ class RepositoryConfigServer(
|
|||
}
|
||||
|
||||
private fun serveWebPage(): Response {
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html", RepositoryWebPage.getHtml(context))
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html", RepositoryWebPage.getHtml())
|
||||
}
|
||||
|
||||
private fun serveLogo(): Response {
|
||||
|
|
@ -120,7 +118,6 @@ class RepositoryConfigServer(
|
|||
|
||||
companion object {
|
||||
fun startOnAvailablePort(
|
||||
context: Context,
|
||||
currentRepositoriesProvider: () -> List<RepositoryInfo>,
|
||||
onChangeProposed: (PendingRepoChange) -> Unit,
|
||||
logoProvider: (() -> ByteArray?)? = null,
|
||||
|
|
@ -129,7 +126,7 @@ class RepositoryConfigServer(
|
|||
): RepositoryConfigServer? {
|
||||
for (port in startPort until startPort + maxAttempts) {
|
||||
try {
|
||||
val server = RepositoryConfigServer(context, currentRepositoriesProvider, onChangeProposed, logoProvider, port)
|
||||
val server = RepositoryConfigServer(currentRepositoriesProvider, onChangeProposed, logoProvider, port)
|
||||
server.start(SOCKET_READ_TIMEOUT, false)
|
||||
return server
|
||||
} catch (e: Exception) {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,14 @@
|
|||
package com.nuvio.tv.core.server
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import com.nuvio.tv.R
|
||||
import java.util.Locale
|
||||
|
||||
object RepositoryWebPage {
|
||||
|
||||
fun getHtml(baseContext: Context): String {
|
||||
val tag = baseContext.getSharedPreferences("app_locale", Context.MODE_PRIVATE)
|
||||
.getString("locale_tag", null)
|
||||
val context = if (!tag.isNullOrEmpty()) {
|
||||
val config = Configuration(baseContext.resources.configuration)
|
||||
config.setLocale(Locale.forLanguageTag(tag))
|
||||
baseContext.createConfigurationContext(config)
|
||||
} else baseContext
|
||||
return """
|
||||
fun getHtml(): String = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>${context.getString(R.string.app_name)} - ${context.getString(R.string.web_manage_repos_title)}</title>
|
||||
<title>NuvioTV - Manage Repositories</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
|
|
@ -318,30 +305,30 @@ object RepositoryWebPage {
|
|||
<div class="page">
|
||||
<div class="header">
|
||||
<img src="/logo.png" alt="NuvioTV" class="header-logo">
|
||||
<p>${context.getString(R.string.web_manage_repos_subtitle)}</p>
|
||||
<p>Manage your repositories</p>
|
||||
</div>
|
||||
|
||||
<div class="add-section">
|
||||
<label>${context.getString(R.string.web_add_repo_url)}</label>
|
||||
<label>Add repository by URL</label>
|
||||
<div class="add-row">
|
||||
<input type="url" id="repoUrl" placeholder="${context.getString(R.string.web_placeholder_url)}" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<button class="btn" id="addBtn" onclick="addRepo()">${context.getString(R.string.web_btn_add)}</button>
|
||||
<input type="url" id="repoUrl" placeholder="https://example.com/manifest.json" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<button class="btn" id="addBtn" onclick="addRepo()">Add</button>
|
||||
</div>
|
||||
<div class="add-error" id="addError"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-label">${context.getString(R.string.web_installed)}</div>
|
||||
<div class="section-label">Installed</div>
|
||||
<ul class="repo-list" id="repoList"></ul>
|
||||
<div class="empty-state" id="emptyState">${context.getString(R.string.web_no_repos)}</div>
|
||||
<div class="empty-state" id="emptyState">No repositories installed</div>
|
||||
|
||||
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">${context.getString(R.string.web_btn_save)}</button>
|
||||
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">Save Changes</button>
|
||||
</div>
|
||||
|
||||
<div class="status-overlay" id="statusOverlay">
|
||||
<div class="status-content" id="statusContent"></div>
|
||||
</div>
|
||||
|
||||
<div class="connection-bar" id="connectionBar">${context.getString(R.string.web_connection_lost)}</div>
|
||||
<div class="connection-bar" id="connectionBar">Connection to TV lost</div>
|
||||
|
||||
<script>
|
||||
var repos = [];
|
||||
|
|
@ -398,13 +385,13 @@ function renderList() {
|
|||
li.innerHTML =
|
||||
'<div class="repo-info">' +
|
||||
'<div class="repo-name">' + escapeHtml(repo.name || repo.url) +
|
||||
(repo.isNew ? '<span class="badge-new">${context.getString(R.string.web_badge_new).replace("'", "\\'")}</span>' : '') +
|
||||
(repo.isNew ? '<span class="badge-new">New</span>' : '') +
|
||||
'</div>' +
|
||||
(repo.description ? '<div class="repo-desc">' + escapeHtml(repo.description) + '</div>' : '') +
|
||||
'<div class="repo-url">' + escapeHtml(repo.url) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="repo-actions">' +
|
||||
'<button class="btn btn-remove" onclick="removeRepo(' + i + ')">${context.getString(R.string.web_btn_remove).replace("'", "\\'")}</button>' +
|
||||
'<button class="btn btn-remove" onclick="removeRepo(' + i + ')">Remove</button>' +
|
||||
'</div>';
|
||||
|
||||
list.appendChild(li);
|
||||
|
|
@ -426,7 +413,7 @@ async function addRepo() {
|
|||
url = url.replace(/\/+$/, '');
|
||||
|
||||
if (repos.some(function(r) { return r.url === url; })) {
|
||||
errorEl.textContent = '${context.getString(R.string.web_error_repo_exists).replace("'", "\\'")}';
|
||||
errorEl.textContent = 'This repository is already in the list';
|
||||
errorEl.style.display = 'block';
|
||||
setTimeout(function() { errorEl.style.display = 'none'; }, 3000);
|
||||
return;
|
||||
|
|
@ -464,7 +451,7 @@ async function saveChanges() {
|
|||
saveBtn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorStatus('${context.getString(R.string.web_error_failed_save).replace("'", "\\'")}');
|
||||
showErrorStatus('Failed to save. Check your connection to the TV.');
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -474,8 +461,8 @@ function showPendingStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="spinner"></div></div>' +
|
||||
'<div class="status-title">${context.getString(R.string.web_status_waiting_tv).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_waiting_tv).replace("'", "\\'")}</div>';
|
||||
'<div class="status-title">Waiting for TV</div>' +
|
||||
'<div class="status-message">Please confirm the changes on your TV to apply them.</div>';
|
||||
content.className = 'status-content';
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
|
@ -484,8 +471,8 @@ function showSuccessStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></div></div>' +
|
||||
'<div class="status-title">${context.getString(R.string.web_status_changes_applied).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_repo_updated).replace("'", "\\'")}</div>';
|
||||
'<div class="status-title">Changes Applied</div>' +
|
||||
'<div class="status-message">Your repository configuration has been updated on the TV.</div>';
|
||||
content.className = 'status-content status-success';
|
||||
setTimeout(dismissStatus, 2500);
|
||||
}
|
||||
|
|
@ -494,8 +481,8 @@ function showRejectedStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></div></div>' +
|
||||
'<div class="status-title">${context.getString(R.string.web_status_changes_rejected).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_changes_rejected).replace("'", "\\'")}</div>';
|
||||
'<div class="status-title">Changes Rejected</div>' +
|
||||
'<div class="status-message">The changes were declined on the TV. Your list has been reverted.</div>';
|
||||
content.className = 'status-content status-rejected';
|
||||
setTimeout(function() {
|
||||
repos = JSON.parse(JSON.stringify(originalRepos));
|
||||
|
|
@ -509,9 +496,9 @@ function showErrorStatus(msg) {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg></div></div>' +
|
||||
'<div class="status-title">${context.getString(R.string.web_status_error).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-title">Something Went Wrong</div>' +
|
||||
'<div class="status-message">' + escapeHtml(msg) + '</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
|
@ -520,9 +507,9 @@ function showTimeoutStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div></div>' +
|
||||
'<div class="status-title">${context.getString(R.string.web_status_timeout).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_timeout).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
'<div class="status-title">Timed Out</div>' +
|
||||
'<div class="status-message">No response from the TV. Please try again.</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
}
|
||||
|
||||
|
|
@ -530,9 +517,9 @@ function showDisconnectedStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22M16.72 11.06A10.94 10.94 0 0 1 19 12.55M5 12.55a10.94 10.94 0 0 1 5.17-2.39M10.71 5.05A16 16 0 0 1 22.56 9M1.42 9a15.91 15.91 0 0 1 4.7-2.88M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/></svg></div></div>' +
|
||||
'<div class="status-title">${context.getString(R.string.web_connection_lost).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_connection_lost).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
'<div class="status-title">Connection Lost</div>' +
|
||||
'<div class="status-message">The TV server is no longer reachable. The changes may have been applied.</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
}
|
||||
|
||||
|
|
@ -604,5 +591,4 @@ loadRepos();
|
|||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ class AddonSyncService @Inject constructor(
|
|||
}
|
||||
|
||||
val localUrls = addonPreferences.installedAddonUrls.first()
|
||||
val userSetNames = addonPreferences.userSetNames.first()
|
||||
Log.d(TAG, "pushToRemote: localUrls count=${localUrls.size} for profile $profileId")
|
||||
|
||||
val params = buildJsonObject {
|
||||
|
|
@ -59,10 +58,6 @@ class AddonSyncService @Inject constructor(
|
|||
addJsonObject {
|
||||
put("url", url)
|
||||
put("sort_order", index)
|
||||
val name = userSetNames[url]
|
||||
if (!name.isNullOrBlank()) {
|
||||
put("name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -101,14 +96,6 @@ class AddonSyncService @Inject constructor(
|
|||
.decodeList<SupabaseAddon>()
|
||||
}
|
||||
|
||||
val nameMap = mutableMapOf<String, String>()
|
||||
remoteAddons.forEach { addon ->
|
||||
if (!addon.name.isNullOrBlank()) {
|
||||
nameMap[canonicalizeUrl(addon.url)] = addon.name
|
||||
}
|
||||
}
|
||||
addonPreferences.setUserSetNames(nameMap)
|
||||
|
||||
Result.success(
|
||||
remoteAddons
|
||||
.sortedBy { it.sortOrder }
|
||||
|
|
@ -119,17 +106,4 @@ class AddonSyncService @Inject constructor(
|
|||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalizeUrl(url: String): String {
|
||||
val trimmed = url.trim().trimEnd('/')
|
||||
val queryStart = trimmed.indexOf('?')
|
||||
val path = if (queryStart >= 0) trimmed.substring(0, queryStart) else trimmed
|
||||
val query = if (queryStart >= 0) trimmed.substring(queryStart) else ""
|
||||
val cleanPath = if (path.endsWith("/manifest.json", ignoreCase = true)) {
|
||||
path.dropLast("/manifest.json".length).trimEnd('/')
|
||||
} else {
|
||||
path.trimEnd('/')
|
||||
}
|
||||
return cleanPath + query
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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])"
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -21,8 +21,6 @@ import javax.inject.Singleton
|
|||
|
||||
private const val TAG = "LibrarySyncService"
|
||||
|
||||
private const val PULL_PAGE_SIZE = 500
|
||||
|
||||
@Singleton
|
||||
class LibrarySyncService @Inject constructor(
|
||||
private val authManager: AuthManager,
|
||||
|
|
@ -68,7 +66,6 @@ class LibrarySyncService @Inject constructor(
|
|||
item.genres.forEach { genre -> add(kotlinx.serialization.json.JsonPrimitive(genre)) }
|
||||
})
|
||||
put("addon_base_url", item.addonBaseUrl)
|
||||
put("added_at", item.addedAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -94,29 +91,17 @@ class LibrarySyncService @Inject constructor(
|
|||
}
|
||||
|
||||
val profileId = profileManager.activeProfileId.value
|
||||
val allItems = mutableListOf<SupabaseLibraryItem>()
|
||||
var offset = 0
|
||||
|
||||
while (true) {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_limit", PULL_PAGE_SIZE)
|
||||
put("p_offset", offset)
|
||||
}
|
||||
val response = withJwtRefreshRetry {
|
||||
postgrest.rpc("sync_pull_library", params)
|
||||
}
|
||||
val page = response.decodeList<SupabaseLibraryItem>()
|
||||
allItems.addAll(page)
|
||||
Log.d(TAG, "pullFromRemote: fetched page at offset=$offset, got ${page.size} items")
|
||||
|
||||
if (page.size < PULL_PAGE_SIZE) break
|
||||
offset += PULL_PAGE_SIZE
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
}
|
||||
val response = withJwtRefreshRetry {
|
||||
postgrest.rpc("sync_pull_library", params)
|
||||
}
|
||||
val remote = response.decodeList<SupabaseLibraryItem>()
|
||||
|
||||
Log.d(TAG, "pullFromRemote: fetched ${allItems.size} total library items for profile $profileId")
|
||||
Log.d(TAG, "pullFromRemote: fetched ${remote.size} library items from Supabase for profile $profileId")
|
||||
|
||||
Result.success(allItems.map { entry ->
|
||||
Result.success(remote.map { entry ->
|
||||
SavedLibraryItem(
|
||||
id = entry.contentId,
|
||||
type = entry.contentType,
|
||||
|
|
@ -128,8 +113,7 @@ class LibrarySyncService @Inject constructor(
|
|||
releaseInfo = entry.releaseInfo,
|
||||
imdbRating = entry.imdbRating,
|
||||
genres = entry.genres,
|
||||
addonBaseUrl = entry.addonBaseUrl,
|
||||
addedAt = entry.addedAt
|
||||
addonBaseUrl = entry.addonBaseUrl
|
||||
)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import com.nuvio.tv.core.auth.AuthManager
|
|||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.nuvio.tv.data.local.PluginDataStore
|
||||
import com.nuvio.tv.data.remote.supabase.SupabasePlugin
|
||||
import com.nuvio.tv.domain.model.RemotePluginInfo
|
||||
import io.github.jan.supabase.postgrest.Postgrest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -61,7 +60,6 @@ class PluginSyncService @Inject constructor(
|
|||
put("name", repo.name)
|
||||
put("enabled", repo.enabled)
|
||||
put("sort_order", index)
|
||||
put("repo_type", repo.type.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -80,7 +78,7 @@ class PluginSyncService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun getRemoteRepoUrls(): Result<List<RemotePluginInfo>> = withContext(Dispatchers.IO) {
|
||||
suspend fun getRemoteRepoUrls(): Result<List<String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val effectiveUserId = authManager.getEffectiveUserId(fallbackToOwnIdOnFailure = false)
|
||||
?: return@withContext Result.failure(
|
||||
|
|
@ -103,7 +101,7 @@ class PluginSyncService @Inject constructor(
|
|||
Result.success(
|
||||
remotePlugins
|
||||
.sortedBy { it.sortOrder }
|
||||
.map { RemotePluginInfo(url = it.url, repoType = it.repoType) }
|
||||
.map { it.url }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get remote repo URLs", e)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,7 @@ import android.util.Log
|
|||
import com.nuvio.tv.core.auth.AuthManager
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.nuvio.tv.data.local.ProfileDataStore
|
||||
import com.nuvio.tv.data.remote.supabase.SupabaseProfileLockState
|
||||
import com.nuvio.tv.data.remote.supabase.SupabaseProfile
|
||||
import com.nuvio.tv.data.remote.supabase.SupabaseProfilePinVerifyResult
|
||||
import com.nuvio.tv.domain.model.UserProfile
|
||||
import io.github.jan.supabase.postgrest.Postgrest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -49,7 +47,6 @@ class ProfileSyncService @Inject constructor(
|
|||
put("avatar_color_hex", profile.avatarColorHex)
|
||||
put("uses_primary_addons", profile.usesPrimaryAddons)
|
||||
put("uses_primary_plugins", profile.usesPrimaryPlugins)
|
||||
put("avatar_id", profile.avatarId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -81,8 +78,7 @@ class ProfileSyncService @Inject constructor(
|
|||
name = entry.name,
|
||||
avatarColorHex = entry.avatarColorHex,
|
||||
usesPrimaryAddons = entry.usesPrimaryAddons,
|
||||
usesPrimaryPlugins = entry.usesPrimaryPlugins,
|
||||
avatarId = entry.avatarId
|
||||
usesPrimaryPlugins = entry.usesPrimaryPlugins
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -114,73 +110,4 @@ class ProfileSyncService @Inject constructor(
|
|||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pullProfileLockStates(): Result<Map<Int, Boolean>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = withJwtRefreshRetry {
|
||||
postgrest.rpc("sync_pull_profile_locks")
|
||||
}
|
||||
val remote = response.decodeList<SupabaseProfileLockState>()
|
||||
val result = remote.associate { it.profileIndex to it.pinEnabled }
|
||||
Result.success(result)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull profile lock states", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setProfilePin(profileId: Int, pin: String, currentPin: String? = null): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_pin", pin)
|
||||
if (!currentPin.isNullOrBlank()) {
|
||||
put("p_current_pin", currentPin)
|
||||
}
|
||||
}
|
||||
withJwtRefreshRetry {
|
||||
postgrest.rpc("set_profile_pin", params)
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set profile PIN", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearProfilePin(profileId: Int, currentPin: String? = null): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
if (!currentPin.isNullOrBlank()) {
|
||||
put("p_current_pin", currentPin)
|
||||
}
|
||||
}
|
||||
withJwtRefreshRetry {
|
||||
postgrest.rpc("clear_profile_pin", params)
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear profile PIN", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyProfilePin(profileId: Int, pin: String): Result<SupabaseProfilePinVerifyResult> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_pin", pin)
|
||||
}
|
||||
val response = withJwtRefreshRetry {
|
||||
postgrest.rpc("verify_profile_pin", params)
|
||||
}
|
||||
val decoded = response.decodeList<SupabaseProfilePinVerifyResult>().firstOrNull()
|
||||
?: SupabaseProfilePinVerifyResult(unlocked = false, retryAfterSeconds = 0)
|
||||
Result.success(decoded)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to verify profile PIN", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -31,12 +29,9 @@ class StartupSyncService @Inject constructor(
|
|||
private val authManager: AuthManager,
|
||||
private val pluginSyncService: PluginSyncService,
|
||||
private val addonSyncService: AddonSyncService,
|
||||
private val collectionSyncService: CollectionSyncService,
|
||||
private val homeCatalogSettingsSyncService: HomeCatalogSettingsSyncService,
|
||||
private val watchProgressSyncService: WatchProgressSyncService,
|
||||
private val librarySyncService: LibrarySyncService,
|
||||
private val watchedItemsSyncService: WatchedItemsSyncService,
|
||||
private val profileSettingsSyncService: ProfileSettingsSyncService,
|
||||
private val profileSyncService: ProfileSyncService,
|
||||
private val pluginManager: PluginManager,
|
||||
private val addonRepository: AddonRepositoryImpl,
|
||||
|
|
@ -51,15 +46,10 @@ class StartupSyncService @Inject constructor(
|
|||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var startupPullJob: Job? = null
|
||||
private var lastPulledKey: String? = null
|
||||
private var lastPulledIncludedProfileSettings: Boolean = false
|
||||
@Volatile
|
||||
private var forceSyncRequested: Boolean = false
|
||||
@Volatile
|
||||
private var forceSyncIncludesProfileSettings: Boolean = true
|
||||
@Volatile
|
||||
private var pendingResyncKey: String? = null
|
||||
@Volatile
|
||||
private var pendingResyncIncludesProfileSettings: Boolean = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
|
|
@ -67,23 +57,15 @@ class StartupSyncService @Inject constructor(
|
|||
when (state) {
|
||||
is AuthState.FullAccount -> {
|
||||
val force = forceSyncRequested
|
||||
val includeProfileSettings = if (force) forceSyncIncludesProfileSettings else true
|
||||
val started = scheduleStartupPull(
|
||||
userId = state.userId,
|
||||
force = force,
|
||||
includeProfileSettings = includeProfileSettings
|
||||
)
|
||||
val started = scheduleStartupPull(state.userId, force = force)
|
||||
if (force && started) forceSyncRequested = false
|
||||
}
|
||||
is AuthState.SignedOut -> {
|
||||
startupPullJob?.cancel()
|
||||
startupPullJob = null
|
||||
lastPulledKey = null
|
||||
lastPulledIncludedProfileSettings = false
|
||||
forceSyncRequested = false
|
||||
forceSyncIncludesProfileSettings = true
|
||||
pendingResyncKey = null
|
||||
pendingResyncIncludesProfileSettings = false
|
||||
}
|
||||
is AuthState.Loading -> Unit
|
||||
}
|
||||
|
|
@ -91,16 +73,11 @@ class StartupSyncService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun requestSyncNow(includeProfileSettings: Boolean = true) {
|
||||
fun requestSyncNow() {
|
||||
forceSyncRequested = true
|
||||
forceSyncIncludesProfileSettings = forceSyncIncludesProfileSettings || includeProfileSettings
|
||||
when (val state = authManager.authState.value) {
|
||||
is AuthState.FullAccount -> {
|
||||
val started = scheduleStartupPull(
|
||||
userId = state.userId,
|
||||
force = true,
|
||||
includeProfileSettings = includeProfileSettings
|
||||
)
|
||||
val started = scheduleStartupPull(state.userId, force = true)
|
||||
if (started) forceSyncRequested = false
|
||||
}
|
||||
else -> Unit
|
||||
|
|
@ -112,40 +89,28 @@ class StartupSyncService @Inject constructor(
|
|||
return "${userId}_p${profileId}"
|
||||
}
|
||||
|
||||
private fun scheduleStartupPull(
|
||||
userId: String,
|
||||
force: Boolean = false,
|
||||
includeProfileSettings: Boolean = true
|
||||
): Boolean {
|
||||
private fun scheduleStartupPull(userId: String, force: Boolean = false): Boolean {
|
||||
val key = pullKey(userId)
|
||||
if (
|
||||
!force &&
|
||||
lastPulledKey == key &&
|
||||
(!includeProfileSettings || lastPulledIncludedProfileSettings)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!force && lastPulledKey == key) return false
|
||||
// Never cancel an active sync — it may be mid-write to DataStore.
|
||||
// Instead, schedule a follow-up sync after the current one finishes.
|
||||
if (startupPullJob?.isActive == true) {
|
||||
if (force) {
|
||||
pendingResyncKey = key
|
||||
pendingResyncIncludesProfileSettings =
|
||||
pendingResyncIncludesProfileSettings || includeProfileSettings
|
||||
}
|
||||
if (force) pendingResyncKey = key
|
||||
return false
|
||||
}
|
||||
|
||||
startupPullJob = scope.launch {
|
||||
val maxAttempts = 3
|
||||
var syncCompleted = false
|
||||
for (attempt in 1..maxAttempts) {
|
||||
val result = pullRemoteData(includeProfileSettings = includeProfileSettings)
|
||||
repeat(maxAttempts) { index ->
|
||||
val attempt = index + 1
|
||||
Log.d(TAG, "Startup sync attempt $attempt/$maxAttempts for key=$key")
|
||||
val result = pullRemoteData()
|
||||
if (result.isSuccess) {
|
||||
lastPulledKey = key
|
||||
lastPulledIncludedProfileSettings = includeProfileSettings
|
||||
Log.d(TAG, "Startup sync completed for key=$key")
|
||||
syncCompleted = true
|
||||
break
|
||||
return@repeat
|
||||
}
|
||||
|
||||
Log.w(TAG, "Startup sync attempt $attempt failed for key=$key", result.exceptionOrNull())
|
||||
|
|
@ -156,26 +121,17 @@ class StartupSyncService @Inject constructor(
|
|||
|
||||
val resyncKey = pendingResyncKey
|
||||
if (resyncKey != null) {
|
||||
val resyncIncludesProfileSettings = pendingResyncIncludesProfileSettings
|
||||
pendingResyncKey = null
|
||||
pendingResyncIncludesProfileSettings = false
|
||||
if (
|
||||
!syncCompleted ||
|
||||
resyncKey != lastPulledKey ||
|
||||
(resyncIncludesProfileSettings && !lastPulledIncludedProfileSettings)
|
||||
) {
|
||||
scheduleStartupPull(
|
||||
userId = userId,
|
||||
force = true,
|
||||
includeProfileSettings = resyncIncludesProfileSettings
|
||||
)
|
||||
if (!syncCompleted || resyncKey != lastPulledKey) {
|
||||
Log.d(TAG, "Running pending re-sync for key=$resyncKey")
|
||||
scheduleStartupPull(userId, force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun pullRemoteData(includeProfileSettings: Boolean): Result<Unit> {
|
||||
private suspend fun pullRemoteData(): Result<Unit> {
|
||||
try {
|
||||
val profileId = profileManager.activeProfileId.value
|
||||
Log.d(TAG, "Pulling remote data for profile $profileId")
|
||||
|
|
@ -184,94 +140,37 @@ class StartupSyncService @Inject constructor(
|
|||
profileSyncService.pullFromRemote().getOrElse { throw it }
|
||||
Log.d(TAG, "Pulled profiles from remote")
|
||||
|
||||
if (includeProfileSettings) {
|
||||
// Pull profile-scoped UI/player/settings blob.
|
||||
// If not present, local settings are preserved.
|
||||
profileSettingsSyncService.pullCurrentProfileFromRemote()
|
||||
.onSuccess { applied ->
|
||||
Log.d(TAG, "Profile settings blob pull completed for profile $profileId (applied=$applied)")
|
||||
}
|
||||
.onFailure { e ->
|
||||
Log.e(TAG, "Failed to pull profile settings blob, keeping local settings", e)
|
||||
}
|
||||
pluginManager.isSyncingFromRemote = true
|
||||
try {
|
||||
val remotePluginUrls = pluginSyncService.getRemoteRepoUrls().getOrElse { throw it }
|
||||
pluginManager.reconcileWithRemoteRepoUrls(
|
||||
remoteUrls = remotePluginUrls,
|
||||
removeMissingLocal = true
|
||||
)
|
||||
Log.d(TAG, "Pulled ${remotePluginUrls.size} plugin repos from remote for profile $profileId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull plugins from remote, keeping local cache", e)
|
||||
} finally {
|
||||
pluginManager.isSyncingFromRemote = false
|
||||
}
|
||||
|
||||
// Run independent syncs in parallel to reduce total startup time.
|
||||
// Plugins, addons, collections, and home catalog settings don't depend on each other.
|
||||
coroutineScope {
|
||||
val pluginJob = async {
|
||||
pluginManager.isSyncingFromRemote = true
|
||||
try {
|
||||
val remotePlugins = pluginSyncService.getRemoteRepoUrls().getOrElse { throw it }
|
||||
pluginManager.reconcileWithRemoteRepoUrls(
|
||||
remotePlugins = remotePlugins,
|
||||
removeMissingLocal = true
|
||||
)
|
||||
Log.d(TAG, "Pulled ${remotePlugins.size} plugin repos from remote for profile $profileId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull plugins from remote, keeping local cache", e)
|
||||
} finally {
|
||||
pluginManager.isSyncingFromRemote = false
|
||||
pluginManager.flushPendingSync()
|
||||
}
|
||||
}
|
||||
|
||||
val addonJob = async {
|
||||
addonRepository.isSyncingFromRemote = true
|
||||
try {
|
||||
val remoteAddonUrls = addonSyncService.getRemoteAddonUrls().getOrElse { throw it }
|
||||
addonRepository.reconcileWithRemoteAddonUrls(
|
||||
remoteUrls = remoteAddonUrls,
|
||||
removeMissingLocal = true
|
||||
)
|
||||
Log.d(TAG, "Pulled ${remoteAddonUrls.size} addons from remote for profile $profileId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull addons from remote, keeping local cache", e)
|
||||
} finally {
|
||||
addonRepository.isSyncingFromRemote = false
|
||||
}
|
||||
}
|
||||
|
||||
val collectionJob = async {
|
||||
try {
|
||||
collectionSyncService.pullFromRemote()
|
||||
.onSuccess { applied ->
|
||||
Log.d(TAG, "Collections pull completed for profile $profileId (applied=$applied)")
|
||||
}
|
||||
.onFailure { e ->
|
||||
Log.e(TAG, "Failed to pull collections from remote, keeping local", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull collections from remote", e)
|
||||
}
|
||||
}
|
||||
|
||||
val homeCatalogJob = async {
|
||||
try {
|
||||
homeCatalogSettingsSyncService.pullFromRemote()
|
||||
.onSuccess { applied ->
|
||||
Log.d(TAG, "Home catalog settings pull completed for profile $profileId (applied=$applied)")
|
||||
}
|
||||
.onFailure { e ->
|
||||
Log.e(TAG, "Failed to pull home catalog settings from remote, keeping local", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull home catalog settings from remote", e)
|
||||
}
|
||||
}
|
||||
|
||||
pluginJob.await()
|
||||
addonJob.await()
|
||||
collectionJob.await()
|
||||
homeCatalogJob.await()
|
||||
addonRepository.isSyncingFromRemote = true
|
||||
try {
|
||||
val remoteAddonUrls = addonSyncService.getRemoteAddonUrls().getOrElse { throw it }
|
||||
addonRepository.reconcileWithRemoteAddonUrls(
|
||||
remoteUrls = remoteAddonUrls,
|
||||
removeMissingLocal = true
|
||||
)
|
||||
Log.d(TAG, "Pulled ${remoteAddonUrls.size} addons from remote for profile $profileId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull addons from remote, keeping local cache", e)
|
||||
} finally {
|
||||
addonRepository.isSyncingFromRemote = false
|
||||
}
|
||||
|
||||
val isTraktConnected = traktAuthDataStore.isEffectivelyAuthenticated.first()
|
||||
val shouldUseSupabaseWatchProgressSync = watchProgressSyncService.shouldUseSupabaseWatchProgressSync()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Watch progress sync: isTraktConnected=$isTraktConnected shouldUseSupabaseWatchProgressSync=$shouldUseSupabaseWatchProgressSync"
|
||||
)
|
||||
val isPrimaryProfile = profileManager.activeProfileId.value == 1
|
||||
val isTraktConnected = isPrimaryProfile && traktAuthDataStore.isAuthenticated.first()
|
||||
Log.d(TAG, "Watch progress sync: isTraktConnected=$isTraktConnected isPrimaryProfile=$isPrimaryProfile")
|
||||
if (!isTraktConnected) {
|
||||
// Pull library and watched items first — these are lightweight and critical.
|
||||
// Watch progress is pulled last because the table is large and may time out;
|
||||
|
|
@ -312,29 +211,6 @@ class StartupSyncService @Inject constructor(
|
|||
} finally {
|
||||
watchProgressRepository.isSyncingFromRemote = false
|
||||
}
|
||||
} else if (shouldUseSupabaseWatchProgressSync) {
|
||||
try {
|
||||
val remoteWatchedItems = watchedItemsSyncService.pullFromRemote().getOrElse { throw it }
|
||||
Log.d(TAG, "Pulled ${remoteWatchedItems.size} watched items from remote")
|
||||
watchedItemsPreferences.replaceWithRemoteItems(remoteWatchedItems)
|
||||
watchProgressRepository.hasCompletedInitialWatchedItemsPull = true
|
||||
Log.d(TAG, "Reconciled local watched items with ${remoteWatchedItems.size} remote items")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull watched items, continuing with Trakt library mode", e)
|
||||
}
|
||||
|
||||
watchProgressRepository.isSyncingFromRemote = true
|
||||
try {
|
||||
val remoteEntries = watchProgressSyncService.pullFromRemote().getOrElse { throw it }
|
||||
Log.d(TAG, "Pulled ${remoteEntries.size} watch progress entries from remote")
|
||||
watchProgressPreferences.mergeRemoteEntries(remoteEntries.toMap())
|
||||
watchProgressRepository.hasCompletedInitialPull = true
|
||||
Log.d(TAG, "Merged local watch progress with ${remoteEntries.size} remote entries")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull watch progress while Trakt is connected, continuing", e)
|
||||
} finally {
|
||||
watchProgressRepository.isSyncingFromRemote = false
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Skipping watch progress & library sync (Trakt connected)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import android.util.Log
|
|||
import com.nuvio.tv.core.auth.AuthManager
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.nuvio.tv.data.local.TraktAuthDataStore
|
||||
import com.nuvio.tv.data.local.TraktSettingsDataStore
|
||||
import com.nuvio.tv.data.local.WatchProgressSource
|
||||
import com.nuvio.tv.data.local.WatchProgressPreferences
|
||||
import com.nuvio.tv.data.remote.supabase.SupabaseWatchProgress
|
||||
import com.nuvio.tv.domain.model.WatchProgress
|
||||
|
|
@ -29,7 +27,6 @@ class WatchProgressSyncService @Inject constructor(
|
|||
private val postgrest: Postgrest,
|
||||
private val watchProgressPreferences: WatchProgressPreferences,
|
||||
private val traktAuthDataStore: TraktAuthDataStore,
|
||||
private val traktSettingsDataStore: TraktSettingsDataStore,
|
||||
private val profileManager: ProfileManager
|
||||
) {
|
||||
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
|
||||
|
|
@ -41,15 +38,10 @@ class WatchProgressSyncService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun shouldUseSupabaseWatchProgressSync(): Boolean {
|
||||
val hasEffectiveTraktConnection = traktAuthDataStore.isEffectivelyAuthenticated.first()
|
||||
val source = traktSettingsDataStore.watchProgressSource.first()
|
||||
return !(hasEffectiveTraktConnection && source == WatchProgressSource.TRAKT)
|
||||
}
|
||||
|
||||
suspend fun deleteFromRemote(keys: Collection<String>): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!shouldUseSupabaseWatchProgressSync()) {
|
||||
if (traktAuthDataStore.isAuthenticated.first()) {
|
||||
Log.d(TAG, "Trakt connected, skipping watch progress delete")
|
||||
return@withContext Result.success(Unit)
|
||||
}
|
||||
|
||||
|
|
@ -85,15 +77,13 @@ class WatchProgressSyncService @Inject constructor(
|
|||
*/
|
||||
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!shouldUseSupabaseWatchProgressSync()) {
|
||||
Log.d(TAG, "Using Trakt watch progress, skipping watch progress push")
|
||||
if (traktAuthDataStore.isAuthenticated.first()) {
|
||||
Log.d(TAG, "Trakt connected, skipping watch progress push")
|
||||
return@withContext Result.success(Unit)
|
||||
}
|
||||
|
||||
val rawEntries = watchProgressPreferences.getAllRawEntries()
|
||||
val entries = canonicalizeForRemote(rawEntries).filterValues { progress ->
|
||||
!(progress.position <= 1L && progress.duration <= 1L && progress.duration > 0L)
|
||||
}
|
||||
val entries = canonicalizeForRemote(rawEntries)
|
||||
Log.d(TAG, "pushToRemote: ${rawEntries.size} local entries, ${entries.size} canonical entries to push")
|
||||
entries.forEach { (key, progress) ->
|
||||
Log.d(TAG, " push entry: key=$key contentId=${progress.contentId} type=${progress.contentType} pos=${progress.position} dur=${progress.duration} lastWatched=${progress.lastWatched}")
|
||||
|
|
@ -133,8 +123,8 @@ class WatchProgressSyncService @Inject constructor(
|
|||
|
||||
suspend fun pushSingleToRemote(key: String, progress: WatchProgress): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!shouldUseSupabaseWatchProgressSync()) {
|
||||
Log.d(TAG, "Using Trakt watch progress, skipping single watch progress push")
|
||||
if (traktAuthDataStore.isAuthenticated.first()) {
|
||||
Log.d(TAG, "Trakt connected, skipping single watch progress push")
|
||||
return@withContext Result.success(Unit)
|
||||
}
|
||||
|
||||
|
|
@ -176,8 +166,8 @@ class WatchProgressSyncService @Inject constructor(
|
|||
*/
|
||||
suspend fun pullFromRemote(): Result<List<Pair<String, WatchProgress>>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!shouldUseSupabaseWatchProgressSync()) {
|
||||
Log.d(TAG, "Using Trakt watch progress, skipping watch progress pull")
|
||||
if (traktAuthDataStore.isAuthenticated.first()) {
|
||||
Log.d(TAG, "Trakt connected, skipping watch progress pull")
|
||||
return@withContext Result.success(emptyList())
|
||||
}
|
||||
|
||||
|
|
@ -286,11 +276,7 @@ class WatchProgressSyncService @Inject constructor(
|
|||
}
|
||||
}
|
||||
.groupBy { it.contentId }
|
||||
.mapValues { (_, episodes) -> episodes.maxWithOrNull(
|
||||
compareBy<WatchProgress> { it.lastWatched }
|
||||
.thenBy { it.season ?: 0 }
|
||||
.thenBy { it.episode ?: 0 }
|
||||
) }
|
||||
.mapValues { (_, episodes) -> episodes.maxByOrNull { it.lastWatched } }
|
||||
|
||||
latestEpisodeByContent.forEach { (contentId, latestEpisode) ->
|
||||
val latest = latestEpisode ?: return@forEach
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import android.util.Log
|
|||
import com.nuvio.tv.core.auth.AuthManager
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.nuvio.tv.data.local.TraktAuthDataStore
|
||||
import com.nuvio.tv.data.local.TraktSettingsDataStore
|
||||
import com.nuvio.tv.data.local.WatchProgressSource
|
||||
import com.nuvio.tv.data.local.WatchedItemsPreferences
|
||||
import com.nuvio.tv.data.remote.supabase.SupabaseWatchedItem
|
||||
import com.nuvio.tv.domain.model.WatchedItem
|
||||
|
|
@ -22,7 +20,6 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
|
||||
private const val TAG = "WatchedItemsSyncService"
|
||||
private const val WATCHED_ITEMS_PAGE_SIZE = 900
|
||||
|
||||
@Singleton
|
||||
class WatchedItemsSyncService @Inject constructor(
|
||||
|
|
@ -30,7 +27,6 @@ class WatchedItemsSyncService @Inject constructor(
|
|||
private val postgrest: Postgrest,
|
||||
private val watchedItemsPreferences: WatchedItemsPreferences,
|
||||
private val traktAuthDataStore: TraktAuthDataStore,
|
||||
private val traktSettingsDataStore: TraktSettingsDataStore,
|
||||
private val profileManager: ProfileManager
|
||||
) {
|
||||
private suspend fun <T> withJwtRefreshRetry(block: suspend () -> T): T {
|
||||
|
|
@ -42,16 +38,10 @@ class WatchedItemsSyncService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun shouldUseSupabaseWatchProgressSync(): Boolean {
|
||||
val hasEffectiveTraktConnection = traktAuthDataStore.isEffectivelyAuthenticated.first()
|
||||
val source = traktSettingsDataStore.watchProgressSource.first()
|
||||
return !(hasEffectiveTraktConnection && source == WatchProgressSource.TRAKT)
|
||||
}
|
||||
|
||||
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!shouldUseSupabaseWatchProgressSync()) {
|
||||
Log.d(TAG, "Using Trakt watch progress, skipping watched items push")
|
||||
if (traktAuthDataStore.isAuthenticated.first()) {
|
||||
Log.d(TAG, "Trakt connected, skipping watched items push")
|
||||
return@withContext Result.success(Unit)
|
||||
}
|
||||
|
||||
|
|
@ -90,116 +80,35 @@ class WatchedItemsSyncService @Inject constructor(
|
|||
|
||||
suspend fun pullFromRemote(): Result<List<WatchedItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!shouldUseSupabaseWatchProgressSync()) {
|
||||
Log.d(TAG, "Using Trakt watch progress, skipping watched items pull")
|
||||
if (traktAuthDataStore.isAuthenticated.first()) {
|
||||
Log.d(TAG, "Trakt connected, skipping watched items pull")
|
||||
return@withContext Result.success(emptyList())
|
||||
}
|
||||
|
||||
val profileId = profileManager.activeProfileId.value
|
||||
val allItems = mutableListOf<WatchedItem>()
|
||||
var page = 1
|
||||
|
||||
while (true) {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_page", page)
|
||||
put("p_page_size", WATCHED_ITEMS_PAGE_SIZE)
|
||||
}
|
||||
val response = withJwtRefreshRetry {
|
||||
postgrest.rpc("sync_pull_watched_items", params)
|
||||
}
|
||||
val remote = response.decodeList<SupabaseWatchedItem>()
|
||||
|
||||
Log.d(TAG, "pullFromRemote: page $page fetched ${remote.size} watched items for profile $profileId")
|
||||
|
||||
allItems.addAll(remote.map { entry ->
|
||||
WatchedItem(
|
||||
contentId = entry.contentId,
|
||||
contentType = entry.contentType,
|
||||
title = entry.title,
|
||||
season = entry.season,
|
||||
episode = entry.episode,
|
||||
watchedAt = entry.watchedAt
|
||||
)
|
||||
})
|
||||
|
||||
if (remote.size < WATCHED_ITEMS_PAGE_SIZE) break
|
||||
page++
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
}
|
||||
val response = withJwtRefreshRetry {
|
||||
postgrest.rpc("sync_pull_watched_items", params)
|
||||
}
|
||||
val remote = response.decodeList<SupabaseWatchedItem>()
|
||||
|
||||
Log.d(TAG, "pullFromRemote: fetched ${allItems.size} total watched items from Supabase for profile $profileId")
|
||||
Result.success(allItems)
|
||||
Log.d(TAG, "pullFromRemote: fetched ${remote.size} watched items from Supabase for profile $profileId")
|
||||
|
||||
Result.success(remote.map { entry ->
|
||||
WatchedItem(
|
||||
contentId = entry.contentId,
|
||||
contentType = entry.contentType,
|
||||
title = entry.title,
|
||||
season = entry.season,
|
||||
episode = entry.episode,
|
||||
watchedAt = entry.watchedAt
|
||||
)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pull watched items from remote", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteFromRemote(
|
||||
contentId: String,
|
||||
season: Int?,
|
||||
episode: Int?
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!shouldUseSupabaseWatchProgressSync()) {
|
||||
return@withContext Result.success(Unit)
|
||||
}
|
||||
|
||||
val profileId = profileManager.activeProfileId.value
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_keys", buildJsonArray {
|
||||
addJsonObject {
|
||||
put("content_id", contentId)
|
||||
if (season != null) put("season", season)
|
||||
if (episode != null) put("episode", episode)
|
||||
}
|
||||
})
|
||||
}
|
||||
withJwtRefreshRetry {
|
||||
postgrest.rpc("sync_delete_watched_items", params)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Deleted watched item from remote: $contentId s=$season e=$episode for profile $profileId")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to delete watched item from remote", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteFromRemoteBatch(
|
||||
contentId: String,
|
||||
episodes: List<Pair<Int, Int>>
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!shouldUseSupabaseWatchProgressSync()) {
|
||||
return@withContext Result.success(Unit)
|
||||
}
|
||||
if (episodes.isEmpty()) return@withContext Result.success(Unit)
|
||||
|
||||
val profileId = profileManager.activeProfileId.value
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_keys", buildJsonArray {
|
||||
episodes.forEach { (season, episode) ->
|
||||
addJsonObject {
|
||||
put("content_id", contentId)
|
||||
put("season", season)
|
||||
put("episode", episode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
withJwtRefreshRetry {
|
||||
postgrest.rpc("sync_delete_watched_items", params)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Batch deleted ${episodes.size} watched items from remote for $contentId profile $profileId")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to batch delete watched items from remote", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package com.nuvio.tv.core.tmdb
|
||||
|
||||
import android.util.Log
|
||||
import com.nuvio.tv.BuildConfig
|
||||
import com.nuvio.tv.data.remote.api.TmdbApi
|
||||
import com.nuvio.tv.data.remote.api.TmdbDiscoverResult
|
||||
import com.nuvio.tv.data.remote.api.TmdbEpisode
|
||||
import com.nuvio.tv.data.remote.api.TmdbImage
|
||||
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast
|
||||
|
|
@ -15,21 +13,18 @@ import com.nuvio.tv.domain.model.MetaCompany
|
|||
import com.nuvio.tv.domain.model.MetaPreview
|
||||
import com.nuvio.tv.domain.model.PersonDetail
|
||||
import com.nuvio.tv.domain.model.PosterShape
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val TAG = "TmdbMetadataService"
|
||||
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY
|
||||
private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
|
||||
|
||||
@Singleton
|
||||
class TmdbMetadataService @Inject constructor(
|
||||
|
|
@ -38,13 +33,8 @@ class TmdbMetadataService @Inject constructor(
|
|||
// In-memory caches
|
||||
private val enrichmentCache = ConcurrentHashMap<String, TmdbEnrichment>()
|
||||
private val episodeCache = ConcurrentHashMap<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
|
||||
private val enrichmentInFlight = ConcurrentHashMap<String, CompletableDeferred<TmdbEnrichment?>>()
|
||||
private val episodeInFlight = ConcurrentHashMap<String, CompletableDeferred<Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>>()
|
||||
private val personCache = ConcurrentHashMap<String, PersonDetail>()
|
||||
private val moreLikeThisCache = ConcurrentHashMap<String, List<MetaPreview>>()
|
||||
private val entityHeaderCache = ConcurrentHashMap<String, TmdbEntityHeader>()
|
||||
private val entityRailCache = ConcurrentHashMap<String, List<MetaPreview>>()
|
||||
private val entityBrowseCache = ConcurrentHashMap<String, TmdbEntityBrowseData>()
|
||||
|
||||
suspend fun fetchEnrichment(
|
||||
tmdbId: String,
|
||||
|
|
@ -55,13 +45,8 @@ class TmdbMetadataService @Inject constructor(
|
|||
val normalizedLanguage = normalizeTmdbLanguage(language)
|
||||
val cacheKey = "$tmdbId:${contentType.name}:$normalizedLanguage"
|
||||
enrichmentCache[cacheKey]?.let { return@withContext it }
|
||||
enrichmentInFlight[cacheKey]?.let { return@withContext it.await() }
|
||||
|
||||
val numericId = tmdbId.toIntOrNull() ?: return@withContext null
|
||||
val requestDeferred = CompletableDeferred<TmdbEnrichment?>()
|
||||
enrichmentInFlight.putIfAbsent(cacheKey, requestDeferred)?.let { existing ->
|
||||
return@withContext existing.await()
|
||||
}
|
||||
val tmdbType = when (contentType) {
|
||||
ContentType.SERIES, ContentType.TV -> "tv"
|
||||
else -> "movie"
|
||||
|
|
@ -121,11 +106,10 @@ class TmdbMetadataService @Inject constructor(
|
|||
val description = details?.overview?.takeIf { it.isNotBlank() }
|
||||
val releaseInfo = details?.releaseDate
|
||||
?: details?.firstAirDate
|
||||
val status = details?.status?.trim()?.takeIf { it.isNotBlank() }
|
||||
val rating = details?.voteAverage
|
||||
val runtime = details?.runtime ?: details?.episodeRunTime?.firstOrNull()
|
||||
val countries = details?.productionCountries
|
||||
?.mapNotNull { it.iso31661?.trim()?.uppercase()?.takeIf { code -> code.isNotBlank() } }
|
||||
?.mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: details?.originCountry?.takeIf { it.isNotEmpty() }
|
||||
val language = details?.originalLanguage?.takeIf { it.isNotBlank() }
|
||||
|
|
@ -136,8 +120,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
MetaCompany(
|
||||
name = name,
|
||||
logo = buildImageUrl(company.logoPath, size = "w300"),
|
||||
tmdbId = company.id
|
||||
logo = buildImageUrl(company.logoPath, size = "w300")
|
||||
)
|
||||
}
|
||||
val networks = details?.networks
|
||||
|
|
@ -146,19 +129,22 @@ class TmdbMetadataService @Inject constructor(
|
|||
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
MetaCompany(
|
||||
name = name,
|
||||
logo = buildImageUrl(network.logoPath, size = "w300"),
|
||||
tmdbId = network.id
|
||||
logo = buildImageUrl(network.logoPath, size = "w300")
|
||||
)
|
||||
}
|
||||
val poster = buildImageUrl(details?.posterPath, size = "w500")
|
||||
val backdrop = buildImageUrl(details?.backdropPath, size = "w1280")
|
||||
|
||||
val collectionId = details?.belongsToCollection?.id
|
||||
val collectionName = details?.belongsToCollection?.name
|
||||
|
||||
val logoPath = images?.logos?.let {
|
||||
selectBestLocalizedImagePath(it, normalizedLanguage)
|
||||
}
|
||||
val logoPath = images?.logos
|
||||
?.sortedWith(
|
||||
compareByDescending<com.nuvio.tv.data.remote.api.TmdbImage> {
|
||||
it.iso6391 == normalizedLanguage.substringBefore("-")
|
||||
}
|
||||
.thenByDescending { it.iso6391 == "en" }
|
||||
.thenByDescending { it.iso6391 == null }
|
||||
)
|
||||
?.firstOrNull()
|
||||
?.filePath
|
||||
|
||||
val logo = buildImageUrl(logoPath, size = "w500")
|
||||
|
||||
|
|
@ -273,7 +259,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
genres.isEmpty() && description == null && backdrop == null && logo == null &&
|
||||
poster == null && castMembers.isEmpty() && director.isEmpty() && writer.isEmpty() &&
|
||||
releaseInfo == null && rating == null && runtime == null && countries.isNullOrEmpty() && language == null &&
|
||||
productionCompanies.isEmpty() && networks.isEmpty() && ageRating == null && status == null
|
||||
productionCompanies.isEmpty() && networks.isEmpty() && ageRating == null
|
||||
) {
|
||||
return@withContext null
|
||||
}
|
||||
|
|
@ -296,24 +282,14 @@ class TmdbMetadataService @Inject constructor(
|
|||
productionCompanies = productionCompanies,
|
||||
networks = networks,
|
||||
ageRating = ageRating,
|
||||
status = status,
|
||||
countries = countries,
|
||||
language = language,
|
||||
collectionId = collectionId,
|
||||
collectionName = collectionName
|
||||
language = language
|
||||
)
|
||||
enrichmentCache[cacheKey] = enrichment
|
||||
requestDeferred.complete(enrichment)
|
||||
enrichment
|
||||
} catch (e: CancellationException) {
|
||||
requestDeferred.cancel(e)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e)
|
||||
requestDeferred.complete(null)
|
||||
null
|
||||
} finally {
|
||||
enrichmentInFlight.remove(cacheKey, requestDeferred)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -325,41 +301,27 @@ class TmdbMetadataService @Inject constructor(
|
|||
val normalizedLanguage = normalizeTmdbLanguage(language)
|
||||
val cacheKey = "$tmdbId:${seasonNumbers.sorted().joinToString(",")}:$normalizedLanguage"
|
||||
episodeCache[cacheKey]?.let { return@withContext it }
|
||||
episodeInFlight[cacheKey]?.let { return@withContext it.await() }
|
||||
|
||||
val numericId = tmdbId.toIntOrNull() ?: return@withContext emptyMap()
|
||||
val requestDeferred = CompletableDeferred<Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
|
||||
episodeInFlight.putIfAbsent(cacheKey, requestDeferred)?.let { existing ->
|
||||
return@withContext existing.await()
|
||||
}
|
||||
val result = mutableMapOf<Pair<Int, Int>, TmdbEpisodeEnrichment>()
|
||||
|
||||
try {
|
||||
seasonNumbers.distinct().forEach { season ->
|
||||
try {
|
||||
val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY, normalizedLanguage)
|
||||
val episodes = response.body()?.episodes.orEmpty()
|
||||
episodes.forEach { ep ->
|
||||
val epNum = ep.episodeNumber ?: return@forEach
|
||||
result[season to epNum] = ep.toEnrichment()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
|
||||
seasonNumbers.distinct().forEach { season ->
|
||||
try {
|
||||
val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY, normalizedLanguage)
|
||||
val episodes = response.body()?.episodes.orEmpty()
|
||||
episodes.forEach { ep ->
|
||||
val epNum = ep.episodeNumber ?: return@forEach
|
||||
result[season to epNum] = ep.toEnrichment()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
|
||||
}
|
||||
|
||||
val finalResult = result.toMap()
|
||||
if (finalResult.isNotEmpty()) {
|
||||
episodeCache[cacheKey] = finalResult
|
||||
}
|
||||
requestDeferred.complete(finalResult)
|
||||
finalResult
|
||||
} catch (e: CancellationException) {
|
||||
requestDeferred.cancel(e)
|
||||
throw e
|
||||
} finally {
|
||||
episodeInFlight.remove(cacheKey, requestDeferred)
|
||||
}
|
||||
|
||||
if (result.isNotEmpty()) {
|
||||
episodeCache[cacheKey] = result
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
suspend fun fetchMoreLikeThis(
|
||||
|
|
@ -444,20 +406,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
|
||||
val backdrop = buildImageUrl(localizedBackdropPath ?: rec.backdropPath, size = "w1280")
|
||||
val fallbackPoster = buildImageUrl(rec.posterPath, size = "w780")
|
||||
|
||||
val releaseInfo = if (recTmdbType == "tv") {
|
||||
val startYear = rec.firstAirDate?.take(4)
|
||||
if (startYear != null) {
|
||||
val tvDetails = runCatching {
|
||||
tmdbApi.getTvDetails(rec.id, TMDB_API_KEY, normalizedLanguage).body()
|
||||
}.getOrNull()
|
||||
val status = tvDetails?.status
|
||||
val endYear = tvDetails?.lastAirDate?.take(4)
|
||||
buildShowYearRange(startYear, endYear, status)
|
||||
} else null
|
||||
} else {
|
||||
rec.releaseDate?.take(4)
|
||||
}
|
||||
val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4)
|
||||
|
||||
MetaPreview(
|
||||
id = "tmdb:${rec.id}",
|
||||
|
|
@ -484,385 +433,17 @@ class TmdbMetadataService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val collectionCache = ConcurrentHashMap<String, List<MetaPreview>>()
|
||||
|
||||
suspend fun fetchMovieCollection(
|
||||
collectionId: Int,
|
||||
language: String = "en"
|
||||
): List<MetaPreview> = withContext(Dispatchers.IO) {
|
||||
val normalizedLanguage = normalizeTmdbLanguage(language)
|
||||
val cacheKey = "$collectionId:$normalizedLanguage:collection"
|
||||
collectionCache[cacheKey]?.let { return@withContext it }
|
||||
|
||||
try {
|
||||
val collectionResponse = tmdbApi.getCollectionDetails(collectionId, TMDB_API_KEY, normalizedLanguage).body()
|
||||
val rawParts = collectionResponse?.parts.orEmpty()
|
||||
|
||||
// Show in release order
|
||||
val sortedParts = rawParts.sortedBy { it.releaseDate ?: "9999" }
|
||||
|
||||
val includeImageLanguage = buildString {
|
||||
append(normalizedLanguage.substringBefore("-"))
|
||||
append(",")
|
||||
append(normalizedLanguage)
|
||||
append(",en,null")
|
||||
}
|
||||
|
||||
val items = coroutineScope {
|
||||
sortedParts.map { part ->
|
||||
async {
|
||||
val title = part.title ?: return@async null
|
||||
|
||||
val localizedBackdropPath = runCatching {
|
||||
tmdbApi.getMovieImages(part.id, TMDB_API_KEY, includeImageLanguage).body()
|
||||
}.getOrNull()?.let { images ->
|
||||
selectBestLocalizedImagePath(
|
||||
images = images.backdrops.orEmpty(),
|
||||
normalizedLanguage = normalizedLanguage
|
||||
)
|
||||
}
|
||||
|
||||
val backdrop = buildImageUrl(localizedBackdropPath ?: part.backdropPath, size = "w1280")
|
||||
val fallbackPoster = buildImageUrl(part.posterPath, size = "w780")
|
||||
val releaseInfo = part.releaseDate?.take(4)
|
||||
|
||||
MetaPreview(
|
||||
id = "tmdb:${part.id}",
|
||||
type = ContentType.MOVIE,
|
||||
name = title,
|
||||
poster = backdrop ?: fallbackPoster,
|
||||
posterShape = PosterShape.LANDSCAPE,
|
||||
background = backdrop,
|
||||
logo = null,
|
||||
description = part.overview?.takeIf { it.isNotBlank() },
|
||||
releaseInfo = releaseInfo,
|
||||
imdbRating = part.voteAverage?.toFloat(),
|
||||
genres = emptyList()
|
||||
)
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
collectionCache[cacheKey] = items
|
||||
items
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch collection for $collectionId: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchEntityBrowse(
|
||||
entityKind: TmdbEntityKind,
|
||||
entityId: Int,
|
||||
sourceType: String,
|
||||
fallbackName: String? = null,
|
||||
language: String = "en"
|
||||
): TmdbEntityBrowseData? = withContext(Dispatchers.IO) {
|
||||
val normalizedLanguage = normalizeTmdbLanguage(language)
|
||||
val normalizedSourceType = normalizeEntitySourceType(sourceType)
|
||||
val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$normalizedLanguage"
|
||||
entityBrowseCache[cacheKey]?.let { return@withContext it }
|
||||
|
||||
val header = fetchEntityHeader(
|
||||
entityKind = entityKind,
|
||||
entityId = entityId,
|
||||
fallbackName = fallbackName,
|
||||
language = normalizedLanguage
|
||||
)
|
||||
|
||||
val rails = buildEntityMediaOrder(entityKind, normalizedSourceType)
|
||||
.flatMap { mediaType ->
|
||||
TmdbEntityRailType.values().mapNotNull { railType ->
|
||||
val pageResult = fetchEntityRailPage(
|
||||
entityKind = entityKind,
|
||||
entityId = entityId,
|
||||
mediaType = mediaType,
|
||||
railType = railType,
|
||||
language = normalizedLanguage,
|
||||
page = 1
|
||||
)
|
||||
val items = pageResult.items
|
||||
if (items.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
TmdbEntityRail(
|
||||
mediaType = mediaType,
|
||||
railType = railType,
|
||||
items = items,
|
||||
currentPage = 1,
|
||||
hasMore = pageResult.hasMore,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (header == null && rails.isEmpty()) return@withContext null
|
||||
|
||||
val data = TmdbEntityBrowseData(
|
||||
header = header ?: TmdbEntityHeader(
|
||||
id = entityId,
|
||||
kind = entityKind,
|
||||
name = fallbackName?.takeIf { it.isNotBlank() } ?: "Unknown",
|
||||
logo = null,
|
||||
originCountry = null,
|
||||
secondaryLabel = null,
|
||||
description = null
|
||||
),
|
||||
rails = rails
|
||||
)
|
||||
entityBrowseCache[cacheKey] = data
|
||||
data
|
||||
}
|
||||
|
||||
private suspend fun fetchEntityHeader(
|
||||
entityKind: TmdbEntityKind,
|
||||
entityId: Int,
|
||||
fallbackName: String?,
|
||||
language: String
|
||||
): TmdbEntityHeader? {
|
||||
val cacheKey = "${entityKind.routeValue}:$entityId:$language:header"
|
||||
entityHeaderCache[cacheKey]?.let { return it }
|
||||
|
||||
val header = try {
|
||||
when (entityKind) {
|
||||
TmdbEntityKind.COMPANY -> {
|
||||
val body = tmdbApi.getCompanyDetails(entityId, TMDB_API_KEY).body()
|
||||
if (body == null) {
|
||||
null
|
||||
} else {
|
||||
TmdbEntityHeader(
|
||||
id = body.id,
|
||||
kind = entityKind,
|
||||
name = body.name?.takeIf { it.isNotBlank() }
|
||||
?: fallbackName?.takeIf { it.isNotBlank() }
|
||||
?: "Unknown",
|
||||
logo = buildImageUrl(body.logoPath, size = "w500"),
|
||||
originCountry = body.originCountry?.takeIf { it.isNotBlank() },
|
||||
secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() },
|
||||
description = body.description?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TmdbEntityKind.NETWORK -> {
|
||||
val body = tmdbApi.getNetworkDetails(entityId, TMDB_API_KEY).body()
|
||||
if (body == null) {
|
||||
null
|
||||
} else {
|
||||
TmdbEntityHeader(
|
||||
id = body.id,
|
||||
kind = entityKind,
|
||||
name = body.name?.takeIf { it.isNotBlank() }
|
||||
?: fallbackName?.takeIf { it.isNotBlank() }
|
||||
?: "Unknown",
|
||||
logo = buildImageUrl(body.logoPath, size = "w500"),
|
||||
originCountry = body.originCountry?.takeIf { it.isNotBlank() },
|
||||
secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() },
|
||||
description = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch ${entityKind.routeValue} header for $entityId: ${e.message}")
|
||||
null
|
||||
} ?: fallbackName?.takeIf { it.isNotBlank() }?.let {
|
||||
TmdbEntityHeader(
|
||||
id = entityId,
|
||||
kind = entityKind,
|
||||
name = it,
|
||||
logo = null,
|
||||
originCountry = null,
|
||||
secondaryLabel = null,
|
||||
description = null
|
||||
)
|
||||
}
|
||||
|
||||
if (header != null) {
|
||||
entityHeaderCache[cacheKey] = header
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
suspend fun fetchEntityRailPage(
|
||||
entityKind: TmdbEntityKind,
|
||||
entityId: Int,
|
||||
mediaType: TmdbEntityMediaType,
|
||||
railType: TmdbEntityRailType,
|
||||
language: String,
|
||||
page: Int
|
||||
): TmdbEntityRailPageResult {
|
||||
if (entityKind == TmdbEntityKind.NETWORK && mediaType == TmdbEntityMediaType.MOVIE) {
|
||||
return TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
|
||||
}
|
||||
|
||||
val cacheKey = "${entityKind.routeValue}:$entityId:${mediaType.value}:${railType.value}:$language:page:$page"
|
||||
entityRailCache[cacheKey]?.let { cached ->
|
||||
return TmdbEntityRailPageResult(
|
||||
items = cached,
|
||||
hasMore = cached.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
val today = LocalDate.now().toString()
|
||||
val voteCountFloor = if (railType == TmdbEntityRailType.TOP_RATED) TOP_RATED_VOTE_COUNT_FLOOR else null
|
||||
val result = try {
|
||||
val response = when (mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> {
|
||||
tmdbApi.discoverMovies(
|
||||
apiKey = TMDB_API_KEY,
|
||||
language = language,
|
||||
page = page,
|
||||
sortBy = movieSortBy(railType),
|
||||
withCompanies = entityId.toString(),
|
||||
releaseDateLte = if (railType == TmdbEntityRailType.RECENT) today else null,
|
||||
voteCountGte = voteCountFloor
|
||||
).body()
|
||||
}
|
||||
|
||||
TmdbEntityMediaType.TV -> {
|
||||
tmdbApi.discoverTv(
|
||||
apiKey = TMDB_API_KEY,
|
||||
language = language,
|
||||
page = page,
|
||||
sortBy = tvSortBy(railType),
|
||||
withCompanies = if (entityKind == TmdbEntityKind.COMPANY) entityId.toString() else null,
|
||||
withNetworks = if (entityKind == TmdbEntityKind.NETWORK) entityId.toString() else null,
|
||||
firstAirDateLte = if (railType == TmdbEntityRailType.RECENT) today else null,
|
||||
voteCountGte = voteCountFloor
|
||||
).body()
|
||||
}
|
||||
}
|
||||
|
||||
val results = response?.results.orEmpty()
|
||||
val totalPages = response?.totalPages ?: page
|
||||
|
||||
val mappedItems = results
|
||||
.filter { it.id > 0 }
|
||||
.mapNotNull { discoverItem ->
|
||||
mapEntityDiscoverResult(
|
||||
result = discoverItem,
|
||||
mediaType = mediaType
|
||||
)
|
||||
}
|
||||
.take(ENTITY_RAIL_MAX_ITEMS)
|
||||
|
||||
TmdbEntityRailPageResult(
|
||||
items = mappedItems,
|
||||
hasMore = page < totalPages && mappedItems.isNotEmpty()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to fetch ${entityKind.routeValue} rail ${railType.value}/${mediaType.value} for $entityId: ${e.message}"
|
||||
)
|
||||
TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
|
||||
}
|
||||
|
||||
if (result.items.isNotEmpty()) {
|
||||
entityRailCache[cacheKey] = result.items
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun mapEntityDiscoverResult(
|
||||
result: TmdbDiscoverResult,
|
||||
mediaType: TmdbEntityMediaType
|
||||
): MetaPreview? {
|
||||
val title = result.title?.takeIf { it.isNotBlank() }
|
||||
?: result.name?.takeIf { it.isNotBlank() }
|
||||
?: result.originalTitle?.takeIf { it.isNotBlank() }
|
||||
?: result.originalName?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
|
||||
val poster = buildImageUrl(result.posterPath, size = "w500")
|
||||
?: buildImageUrl(result.backdropPath, size = "w780")
|
||||
?: return null
|
||||
val background = buildImageUrl(result.backdropPath, size = "w1280")
|
||||
val releaseInfo = when (mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4)
|
||||
TmdbEntityMediaType.TV -> result.firstAirDate?.take(4)
|
||||
}
|
||||
|
||||
return MetaPreview(
|
||||
id = "tmdb:${result.id}",
|
||||
type = if (mediaType == TmdbEntityMediaType.TV) ContentType.SERIES else ContentType.MOVIE,
|
||||
name = title,
|
||||
poster = poster,
|
||||
posterShape = PosterShape.POSTER,
|
||||
background = background,
|
||||
logo = null,
|
||||
description = result.overview?.takeIf { it.isNotBlank() },
|
||||
releaseInfo = releaseInfo,
|
||||
imdbRating = result.voteAverage?.toFloat(),
|
||||
genres = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
internal fun buildEntityMediaOrder(
|
||||
entityKind: TmdbEntityKind,
|
||||
sourceType: String
|
||||
): List<TmdbEntityMediaType> {
|
||||
if (entityKind == TmdbEntityKind.NETWORK) {
|
||||
return listOf(TmdbEntityMediaType.TV)
|
||||
}
|
||||
|
||||
return when (normalizeEntitySourceType(sourceType)) {
|
||||
"movie" -> listOf(TmdbEntityMediaType.MOVIE, TmdbEntityMediaType.TV)
|
||||
else -> listOf(TmdbEntityMediaType.TV, TmdbEntityMediaType.MOVIE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeEntitySourceType(sourceType: String): String {
|
||||
return when (sourceType.trim().lowercase(Locale.US)) {
|
||||
"movie" -> "movie"
|
||||
"tv", "series", "show" -> "tv"
|
||||
else -> "tv"
|
||||
}
|
||||
}
|
||||
|
||||
private fun movieSortBy(railType: TmdbEntityRailType): String = when (railType) {
|
||||
TmdbEntityRailType.POPULAR -> "popularity.desc"
|
||||
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
|
||||
TmdbEntityRailType.RECENT -> "primary_release_date.desc"
|
||||
}
|
||||
|
||||
private fun tvSortBy(railType: TmdbEntityRailType): String = when (railType) {
|
||||
TmdbEntityRailType.POPULAR -> "popularity.desc"
|
||||
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
|
||||
TmdbEntityRailType.RECENT -> "first_air_date.desc"
|
||||
}
|
||||
|
||||
private fun buildShowYearRange(startYear: String, endYear: String?, status: String?): String {
|
||||
val isEnded = status != null && status != "Returning Series" && status != "In Production"
|
||||
return when {
|
||||
isEnded && endYear != null && endYear != startYear -> "$startYear - $endYear"
|
||||
isEnded -> startYear
|
||||
else -> "$startYear - "
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildImageUrl(path: String?, size: String): String? {
|
||||
val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return "https://image.tmdb.org/t/p/$size$clean"
|
||||
}
|
||||
|
||||
private fun normalizeTmdbLanguage(language: String?): String {
|
||||
val raw = language
|
||||
return language
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.replace('_', '-')
|
||||
?: return "en"
|
||||
// Normalize region code to uppercase (e.g. pt-br -> pt-BR)
|
||||
val normalized = raw.split("-").let { parts ->
|
||||
if (parts.size == 2) "${parts[0].lowercase(Locale.US)}-${parts[1].uppercase(Locale.US)}"
|
||||
else raw.lowercase(Locale.US)
|
||||
}
|
||||
// Map codes unsupported by TMDB to their closest equivalent
|
||||
return when (normalized) {
|
||||
"es-419" -> "es-MX"
|
||||
else -> normalized
|
||||
}
|
||||
?: "en"
|
||||
}
|
||||
|
||||
private fun selectBestLocalizedImagePath(
|
||||
|
|
@ -871,12 +452,9 @@ class TmdbMetadataService @Inject constructor(
|
|||
): String? {
|
||||
if (images.isEmpty()) return null
|
||||
val languageCode = normalizedLanguage.substringBefore("-")
|
||||
val regionCode = normalizedLanguage.substringAfter("-", "").uppercase(Locale.US).takeIf { it.length == 2 }
|
||||
?: DEFAULT_LANGUAGE_REGIONS[languageCode]
|
||||
return images
|
||||
.sortedWith(
|
||||
compareByDescending<TmdbImage> { it.iso6391 == languageCode && it.iso31661 == regionCode }
|
||||
.thenByDescending { it.iso6391 == languageCode && it.iso31661 == null }
|
||||
compareByDescending<TmdbImage> { it.iso6391 == normalizedLanguage }
|
||||
.thenByDescending { it.iso6391 == languageCode }
|
||||
.thenByDescending { it.iso6391 == "en" }
|
||||
.thenByDescending { it.iso6391 == null }
|
||||
|
|
@ -885,47 +463,27 @@ class TmdbMetadataService @Inject constructor(
|
|||
?.filePath
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_LANGUAGE_REGIONS = mapOf(
|
||||
"pt" to "PT",
|
||||
"es" to "ES"
|
||||
)
|
||||
private const val ENTITY_RAIL_MAX_ITEMS = 20
|
||||
private const val TOP_RATED_VOTE_COUNT_FLOOR = 200
|
||||
}
|
||||
|
||||
suspend fun fetchPersonDetail(
|
||||
personId: Int,
|
||||
preferCrewCredits: Boolean? = null,
|
||||
language: String = "en"
|
||||
preferCrewCredits: Boolean? = null
|
||||
): PersonDetail? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val normalizedLanguage = normalizeTmdbLanguage(language)
|
||||
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}:$normalizedLanguage"
|
||||
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}"
|
||||
personCache[cacheKey]?.let { return@withContext it }
|
||||
|
||||
try {
|
||||
val (person, credits) = coroutineScope {
|
||||
val personDeferred = async {
|
||||
tmdbApi.getPersonDetails(personId, TMDB_API_KEY, normalizedLanguage).body()
|
||||
tmdbApi.getPersonDetails(personId, TMDB_API_KEY).body()
|
||||
}
|
||||
val creditsDeferred = async {
|
||||
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY, normalizedLanguage).body()
|
||||
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY).body()
|
||||
}
|
||||
Pair(personDeferred.await(), creditsDeferred.await())
|
||||
}
|
||||
|
||||
if (person == null) return@withContext null
|
||||
|
||||
// If biography is empty and language is not English, fetch English fallback
|
||||
val biography = if (person.biography.isNullOrBlank() && normalizedLanguage != "en") {
|
||||
runCatching {
|
||||
tmdbApi.getPersonDetails(personId, TMDB_API_KEY, "en").body()?.biography
|
||||
}.getOrNull()
|
||||
} else {
|
||||
person.biography
|
||||
}?.takeIf { it.isNotBlank() }
|
||||
|
||||
val preferCrewFilmography = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment)
|
||||
|
||||
val castMovieCredits = mapMovieCreditsFromCast(credits?.cast.orEmpty())
|
||||
|
|
@ -947,7 +505,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
val detail = PersonDetail(
|
||||
tmdbId = person.id,
|
||||
name = person.name ?: "Unknown",
|
||||
biography = biography,
|
||||
biography = person.biography?.takeIf { it.isNotBlank() },
|
||||
birthday = person.birthday?.takeIf { it.isNotBlank() },
|
||||
deathday = person.deathday?.takeIf { it.isNotBlank() },
|
||||
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
|
||||
|
|
@ -1141,11 +699,8 @@ data class TmdbEnrichment(
|
|||
val productionCompanies: List<MetaCompany>,
|
||||
val networks: List<MetaCompany>,
|
||||
val ageRating: String?,
|
||||
val status: String?,
|
||||
val countries: List<String>?,
|
||||
val language: String?,
|
||||
val collectionId: Int?,
|
||||
val collectionName: String?
|
||||
val language: String?
|
||||
)
|
||||
|
||||
data class TmdbEpisodeEnrichment(
|
||||
|
|
@ -1156,58 +711,6 @@ data class TmdbEpisodeEnrichment(
|
|||
val runtimeMinutes: Int?
|
||||
)
|
||||
|
||||
enum class TmdbEntityKind(val routeValue: String) {
|
||||
COMPANY("company"),
|
||||
NETWORK("network");
|
||||
|
||||
companion object {
|
||||
fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase(Locale.US)) {
|
||||
"network" -> NETWORK
|
||||
else -> COMPANY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class TmdbEntityMediaType(val value: String) {
|
||||
MOVIE("movie"),
|
||||
TV("tv")
|
||||
}
|
||||
|
||||
enum class TmdbEntityRailType(val value: String) {
|
||||
POPULAR("popular"),
|
||||
TOP_RATED("top_rated"),
|
||||
RECENT("recent")
|
||||
}
|
||||
|
||||
data class TmdbEntityHeader(
|
||||
val id: Int,
|
||||
val kind: TmdbEntityKind,
|
||||
val name: String,
|
||||
val logo: String?,
|
||||
val originCountry: String?,
|
||||
val secondaryLabel: String?,
|
||||
val description: String?
|
||||
)
|
||||
|
||||
data class TmdbEntityRail(
|
||||
val mediaType: TmdbEntityMediaType,
|
||||
val railType: TmdbEntityRailType,
|
||||
val items: List<MetaPreview>,
|
||||
val currentPage: Int = 1,
|
||||
val hasMore: Boolean = false,
|
||||
val isLoading: Boolean = false
|
||||
)
|
||||
|
||||
data class TmdbEntityBrowseData(
|
||||
val header: TmdbEntityHeader,
|
||||
val rails: List<TmdbEntityRail>
|
||||
)
|
||||
|
||||
data class TmdbEntityRailPageResult(
|
||||
val items: List<MetaPreview>,
|
||||
val hasMore: Boolean
|
||||
)
|
||||
|
||||
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
|
||||
val title = name?.takeIf { it.isNotBlank() }
|
||||
val overview = overview?.takeIf { it.isNotBlank() }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
package com.nuvio.tv.core.tmdb
|
||||
|
||||
import android.util.Log
|
||||
import com.nuvio.tv.BuildConfig
|
||||
import com.nuvio.tv.data.remote.api.TmdbApi
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
|
@ -14,7 +11,7 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
|
||||
private const val TAG = "TmdbService"
|
||||
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY
|
||||
private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
|
||||
|
||||
/**
|
||||
* Service to handle TMDB ID conversions and lookups.
|
||||
|
|
@ -29,9 +26,6 @@ class TmdbService @Inject constructor(
|
|||
|
||||
// Cache: TMDB ID -> IMDB ID
|
||||
private val tmdbToImdbCache = ConcurrentHashMap<Int, String>()
|
||||
|
||||
private val imdbToTmdbInFlight = ConcurrentHashMap<String, CompletableDeferred<Int?>>()
|
||||
private val tmdbToImdbInFlight = ConcurrentHashMap<String, CompletableDeferred<String?>>()
|
||||
|
||||
// Mutex for thread-safe cache operations
|
||||
private val cacheMutex = Mutex()
|
||||
|
|
@ -56,13 +50,6 @@ class TmdbService @Inject constructor(
|
|||
return@withContext cached
|
||||
}
|
||||
|
||||
val normalizedType = normalizeMediaType(mediaType)
|
||||
val requestKey = "$imdbId:$normalizedType"
|
||||
val requestDeferred = CompletableDeferred<Int?>()
|
||||
imdbToTmdbInFlight.putIfAbsent(requestKey, requestDeferred)?.let { existing ->
|
||||
return@withContext existing.await()
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Looking up TMDB ID for IMDB: $imdbId (type: $mediaType)")
|
||||
|
||||
|
|
@ -74,17 +61,13 @@ class TmdbService @Inject constructor(
|
|||
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
|
||||
requestDeferred.complete(null)
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
if (body == null) {
|
||||
requestDeferred.complete(null)
|
||||
return@withContext null
|
||||
}
|
||||
val body = response.body() ?: return@withContext null
|
||||
|
||||
// Determine which results to use based on media type
|
||||
val normalizedType = normalizeMediaType(mediaType)
|
||||
val result = when (normalizedType) {
|
||||
"movie" -> body.movieResults?.firstOrNull()
|
||||
"tv", "series" -> body.tvResults?.firstOrNull()
|
||||
|
|
@ -99,25 +82,16 @@ class TmdbService @Inject constructor(
|
|||
imdbToTmdbCache[imdbId] = found.id
|
||||
tmdbToImdbCache[found.id] = imdbId
|
||||
}
|
||||
|
||||
requestDeferred.complete(found.id)
|
||||
|
||||
|
||||
return@withContext found.id
|
||||
}
|
||||
|
||||
Log.w(TAG, "No TMDB result found for IMDB: $imdbId")
|
||||
requestDeferred.complete(null)
|
||||
null
|
||||
|
||||
} catch (e: CancellationException) {
|
||||
requestDeferred.cancel(e)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e)
|
||||
requestDeferred.complete(null)
|
||||
null
|
||||
} finally {
|
||||
imdbToTmdbInFlight.remove(requestKey, requestDeferred)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,16 +109,10 @@ class TmdbService @Inject constructor(
|
|||
return@withContext cached
|
||||
}
|
||||
|
||||
val normalizedType = normalizeMediaType(mediaType)
|
||||
val requestKey = "$tmdbId:$normalizedType"
|
||||
val requestDeferred = CompletableDeferred<String?>()
|
||||
tmdbToImdbInFlight.putIfAbsent(requestKey, requestDeferred)?.let { existing ->
|
||||
return@withContext existing.await()
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Looking up IMDB ID for TMDB: $tmdbId (type: $mediaType)")
|
||||
|
||||
val normalizedType = normalizeMediaType(mediaType)
|
||||
val response = when (normalizedType) {
|
||||
"movie" -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY)
|
||||
"tv", "series" -> tmdbApi.getTvExternalIds(tmdbId, TMDB_API_KEY)
|
||||
|
|
@ -153,15 +121,10 @@ class TmdbService @Inject constructor(
|
|||
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
|
||||
requestDeferred.complete(null)
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
if (body == null) {
|
||||
requestDeferred.complete(null)
|
||||
return@withContext null
|
||||
}
|
||||
val body = response.body() ?: return@withContext null
|
||||
|
||||
body.imdbId?.let { imdbId ->
|
||||
Log.d(TAG, "Found IMDB ID: $imdbId for TMDB: $tmdbId")
|
||||
|
|
@ -171,25 +134,16 @@ class TmdbService @Inject constructor(
|
|||
tmdbToImdbCache[tmdbId] = imdbId
|
||||
imdbToTmdbCache[imdbId] = tmdbId
|
||||
}
|
||||
|
||||
requestDeferred.complete(imdbId)
|
||||
|
||||
|
||||
return@withContext imdbId
|
||||
}
|
||||
|
||||
Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId")
|
||||
requestDeferred.complete(null)
|
||||
null
|
||||
|
||||
} catch (e: CancellationException) {
|
||||
requestDeferred.cancel(e)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e)
|
||||
requestDeferred.complete(null)
|
||||
null
|
||||
} finally {
|
||||
tmdbToImdbInFlight.remove(requestKey, requestDeferred)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,8 +202,6 @@ class TmdbService @Inject constructor(
|
|||
fun clearCache() {
|
||||
imdbToTmdbCache.clear()
|
||||
tmdbToImdbCache.clear()
|
||||
imdbToTmdbInFlight.clear()
|
||||
tmdbToImdbInFlight.clear()
|
||||
Log.d(TAG, "Cache cleared")
|
||||
}
|
||||
|
||||
|
|
@ -260,9 +212,4 @@ class TmdbService @Inject constructor(
|
|||
imdbToTmdbCache[imdbId] = tmdbId
|
||||
tmdbToImdbCache[tmdbId] = imdbId
|
||||
}
|
||||
|
||||
/** Returns the cached TMDB ID for an IMDB ID without making any network call. */
|
||||
fun cachedTmdbId(imdbId: String): Int? = imdbToTmdbCache[imdbId]
|
||||
|
||||
fun apiKey(): String = TMDB_API_KEY
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package com.nuvio.tv.core.torrent
|
||||
|
||||
class TorrentException(message: String) : Exception(message)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -46,20 +46,15 @@ class AddonPreferences @Inject constructor(
|
|||
private val gson = Gson()
|
||||
private val orderedUrlsKey = stringPreferencesKey("installed_addon_urls_ordered")
|
||||
private val legacyUrlsKey = stringSetPreferencesKey("installed_addon_urls")
|
||||
private val userSetNamesKey = stringPreferencesKey("addon_user_set_names")
|
||||
private val manifestSuffix = "/manifest.json"
|
||||
|
||||
private fun canonicalizeUrl(url: String): String {
|
||||
val trimmed = url.trim().trimEnd('/')
|
||||
val queryStart = trimmed.indexOf('?')
|
||||
val path = if (queryStart >= 0) trimmed.substring(0, queryStart) else trimmed
|
||||
val query = if (queryStart >= 0) trimmed.substring(queryStart) else ""
|
||||
val cleanPath = if (path.endsWith(manifestSuffix, ignoreCase = true)) {
|
||||
path.dropLast(manifestSuffix.length).trimEnd('/')
|
||||
return if (trimmed.endsWith(manifestSuffix, ignoreCase = true)) {
|
||||
trimmed.dropLast(manifestSuffix.length).trimEnd('/')
|
||||
} else {
|
||||
path.trimEnd('/')
|
||||
trimmed
|
||||
}
|
||||
return cleanPath + query
|
||||
}
|
||||
|
||||
val installedAddonUrls: Flow<List<String>> = effectiveProfileIdFlow.flatMapLatest { pid ->
|
||||
|
|
@ -138,28 +133,6 @@ class AddonPreferences @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val userSetNames: Flow<Map<String, String>> = effectiveProfileIdFlow.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { preferences ->
|
||||
val json = preferences[userSetNamesKey]
|
||||
if (json != null) parseNameMap(json) else emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setUserSetNames(names: Map<String, String>) {
|
||||
store().edit { preferences ->
|
||||
preferences[userSetNamesKey] = gson.toJson(names)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNameMap(json: String): Map<String, String> {
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyMap()
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDefaultAddons(): Set<String> = setOf(
|
||||
"https://v3-cinemeta.strem.io",
|
||||
"https://opensubtitles-v3.strem.io"
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,22 +3,13 @@ package com.nuvio.tv.data.local
|
|||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.nuvio.tv.domain.model.FocusedPosterTrailerPlaybackTarget
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.nuvio.tv.core.sync.LocalHomeCatalogSettingsState
|
||||
import com.nuvio.tv.core.sync.SyncHomeCatalogPayload
|
||||
import com.nuvio.tv.core.sync.buildHomeCatalogSyncPayload
|
||||
import com.nuvio.tv.core.sync.homeCatalogKey
|
||||
import com.nuvio.tv.core.sync.homeCollectionKey
|
||||
import com.nuvio.tv.domain.model.Addon
|
||||
import com.nuvio.tv.domain.model.Collection
|
||||
import com.nuvio.tv.domain.model.FocusedPosterTrailerPlaybackTarget
|
||||
import com.nuvio.tv.domain.model.HomeLayout
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
|
@ -35,7 +26,7 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
private const val DEFAULT_POSTER_CARD_HEIGHT_DP = 189
|
||||
private const val DEFAULT_POSTER_CARD_CORNER_RADIUS_DP = 12
|
||||
private const val DEFAULT_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 3
|
||||
private const val MIN_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 0
|
||||
private const val MIN_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 1
|
||||
}
|
||||
|
||||
private fun store(profileId: Int = profileManager.activeProfileId.value) =
|
||||
|
|
@ -49,7 +40,6 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
private val heroCatalogKeysKey = stringPreferencesKey("hero_catalog_keys")
|
||||
private val homeCatalogOrderKeysKey = stringPreferencesKey("home_catalog_order_keys")
|
||||
private val disabledHomeCatalogKeysKey = stringPreferencesKey("disabled_home_catalog_keys")
|
||||
private val customCatalogTitlesKey = stringPreferencesKey("custom_catalog_titles")
|
||||
private val sidebarCollapsedKey = booleanPreferencesKey("sidebar_collapsed_by_default")
|
||||
private val modernSidebarEnabledKey = booleanPreferencesKey("modern_sidebar_enabled")
|
||||
private val legacyModernSidebarEnabledKey = booleanPreferencesKey("glass_sidepanel_enabled")
|
||||
|
|
@ -70,13 +60,8 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
private val posterCardHeightDpKey = intPreferencesKey("poster_card_height_dp")
|
||||
private val posterCardCornerRadiusDpKey = intPreferencesKey("poster_card_corner_radius_dp")
|
||||
private val blurUnwatchedEpisodesKey = booleanPreferencesKey("blur_unwatched_episodes")
|
||||
private val blurContinueWatchingNextUpKey = booleanPreferencesKey("blur_continue_watching_next_up")
|
||||
private val detailPageTrailerButtonEnabledKey = booleanPreferencesKey("detail_page_trailer_button_enabled")
|
||||
private val preferExternalMetaAddonDetailKey = booleanPreferencesKey("prefer_external_meta_addon_detail")
|
||||
private val modernHeroFullScreenBackdropKey = booleanPreferencesKey("modern_hero_full_screen_backdrop")
|
||||
private val hideUnreleasedContentKey = booleanPreferencesKey("hide_unreleased_content")
|
||||
private val showFullReleaseDateKey = booleanPreferencesKey("show_full_release_date")
|
||||
private val memoryOnlyVerticalScrollKey = booleanPreferencesKey("memory_only_vertical_scroll")
|
||||
|
||||
private fun <T> profileFlow(extract: (prefs: androidx.datastore.preferences.core.Preferences) -> T): Flow<T> =
|
||||
profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
|
|
@ -121,10 +106,6 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
parseCatalogKeys(prefs[disabledHomeCatalogKeysKey])
|
||||
}
|
||||
|
||||
val customCatalogTitles: Flow<Map<String, String>> = profileFlow { prefs ->
|
||||
parseCustomTitles(prefs[customCatalogTitlesKey])
|
||||
}
|
||||
|
||||
val sidebarCollapsedByDefault: Flow<Boolean> = profileFlow { prefs ->
|
||||
val modernSidebarEnabled =
|
||||
prefs[modernSidebarEnabledKey] ?: prefs[legacyModernSidebarEnabledKey] ?: false
|
||||
|
|
@ -147,10 +128,6 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
prefs[modernLandscapePostersEnabledKey] ?: false
|
||||
}
|
||||
|
||||
val modernHeroFullScreenBackdropEnabled: Flow<Boolean> = profileFlow { prefs ->
|
||||
prefs[modernHeroFullScreenBackdropKey] ?: false
|
||||
}
|
||||
|
||||
val heroSectionEnabled: Flow<Boolean> = profileFlow { prefs ->
|
||||
prefs[heroSectionEnabledKey] ?: true
|
||||
}
|
||||
|
|
@ -213,34 +190,12 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
prefs[blurUnwatchedEpisodesKey] ?: false
|
||||
}
|
||||
|
||||
val blurContinueWatchingNextUp: Flow<Boolean> = profileFlow { prefs ->
|
||||
prefs[blurContinueWatchingNextUpKey] ?: false
|
||||
}
|
||||
|
||||
val detailPageTrailerButtonEnabled: Flow<Boolean> = profileFlow { prefs ->
|
||||
prefs[detailPageTrailerButtonEnabledKey] ?: false
|
||||
}
|
||||
|
||||
val preferExternalMetaAddonDetail: Flow<Boolean> = profileFlow { prefs ->
|
||||
prefs[preferExternalMetaAddonDetailKey] ?: true
|
||||
}
|
||||
|
||||
val hideUnreleasedContent: Flow<Boolean> = profileFlow { prefs ->
|
||||
prefs[hideUnreleasedContentKey] ?: false
|
||||
}
|
||||
|
||||
val showFullReleaseDate: Flow<Boolean> = profileFlow { prefs ->
|
||||
prefs[showFullReleaseDateKey] ?: true
|
||||
}
|
||||
|
||||
val memoryOnlyVerticalScroll: Flow<Boolean> = profileFlow { prefs ->
|
||||
prefs[memoryOnlyVerticalScrollKey] ?: false
|
||||
}
|
||||
|
||||
suspend fun setMemoryOnlyVerticalScroll(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[memoryOnlyVerticalScrollKey] = enabled
|
||||
}
|
||||
prefs[preferExternalMetaAddonDetailKey] ?: false
|
||||
}
|
||||
|
||||
suspend fun setLayout(layout: HomeLayout) {
|
||||
|
|
@ -328,12 +283,6 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setModernHeroFullScreenBackdropEnabled(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[modernHeroFullScreenBackdropKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHeroSectionEnabled(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[heroSectionEnabledKey] = enabled
|
||||
|
|
@ -428,12 +377,6 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setBlurContinueWatchingNextUp(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[blurContinueWatchingNextUpKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDetailPageTrailerButtonEnabled(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[detailPageTrailerButtonEnabledKey] = enabled
|
||||
|
|
@ -446,18 +389,6 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setHideUnreleasedContent(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[hideUnreleasedContentKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setShowFullReleaseDate(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[showFullReleaseDateKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCatalogKeys(json: String?): List<String> {
|
||||
if (json.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
|
|
@ -476,83 +407,4 @@ class LayoutPreferenceDataStore @Inject constructor(
|
|||
.distinct()
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun parseCustomTitles(json: String?): Map<String, String> {
|
||||
if (json.isNullOrBlank()) return emptyMap()
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
gson.fromJson<Map<String, String>>(json, type).orEmpty()
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setCustomCatalogTitles(titles: Map<String, String>) {
|
||||
store().edit { prefs ->
|
||||
val filtered = titles.filterValues { it.isNotBlank() }
|
||||
if (filtered.isEmpty()) {
|
||||
prefs.remove(customCatalogTitlesKey)
|
||||
} else {
|
||||
prefs[customCatalogTitlesKey] = gson.toJson(filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun getHomeCatalogSettingsState(): LocalHomeCatalogSettingsState {
|
||||
return readHomeCatalogSettingsState(store().data.first())
|
||||
}
|
||||
|
||||
internal suspend fun exportCatalogSettingsToSyncPayload(
|
||||
addons: List<Addon>,
|
||||
collections: List<Collection>
|
||||
): SyncHomeCatalogPayload {
|
||||
return buildHomeCatalogSyncPayload(
|
||||
addons = addons,
|
||||
collections = collections,
|
||||
localState = getHomeCatalogSettingsState()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun applyCatalogSettingsFromRemote(payload: SyncHomeCatalogPayload) {
|
||||
val sortedItems = payload.items.sortedBy { it.order }
|
||||
val orderKeys = sortedItems.map { item ->
|
||||
if (item.isCollection) homeCollectionKey(item.collectionId)
|
||||
else homeCatalogKey(item.addonId, item.type, item.catalogId)
|
||||
}
|
||||
val disabledKeys = sortedItems.filter { !it.enabled }.map { item ->
|
||||
if (item.isCollection) homeCollectionKey(item.collectionId)
|
||||
else homeCatalogKey(item.addonId, item.type, item.catalogId)
|
||||
}
|
||||
val titles = sortedItems.associate { item ->
|
||||
val key = if (item.isCollection) homeCollectionKey(item.collectionId)
|
||||
else homeCatalogKey(item.addonId, item.type, item.catalogId)
|
||||
key to item.customTitle
|
||||
}.filterValues { it.isNotBlank() }
|
||||
|
||||
store().edit { prefs ->
|
||||
if (orderKeys.isNotEmpty()) {
|
||||
prefs[homeCatalogOrderKeysKey] = gson.toJson(orderKeys)
|
||||
} else {
|
||||
prefs.remove(homeCatalogOrderKeysKey)
|
||||
}
|
||||
if (disabledKeys.isNotEmpty()) {
|
||||
prefs[disabledHomeCatalogKeysKey] = gson.toJson(disabledKeys)
|
||||
} else {
|
||||
prefs.remove(disabledHomeCatalogKeysKey)
|
||||
}
|
||||
if (titles.isNotEmpty()) {
|
||||
prefs[customCatalogTitlesKey] = gson.toJson(titles)
|
||||
} else {
|
||||
prefs.remove(customCatalogTitlesKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readHomeCatalogSettingsState(prefs: Preferences): LocalHomeCatalogSettingsState {
|
||||
return LocalHomeCatalogSettingsState(
|
||||
orderKeys = parseCatalogKeys(prefs[homeCatalogOrderKeysKey]),
|
||||
disabledKeys = parseCatalogKeys(prefs[disabledHomeCatalogKeysKey]).toSet(),
|
||||
customTitles = parseCustomTitles(prefs[customCatalogTitlesKey])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.nuvio.tv.data.local
|
|||
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.google.gson.Gson
|
||||
|
|
@ -29,19 +28,6 @@ class LibraryPreferences @Inject constructor(
|
|||
|
||||
private val gson = Gson()
|
||||
private val libraryItemsKey = stringSetPreferencesKey("library_items")
|
||||
private val sortOptionKey = stringPreferencesKey("library_sort_option")
|
||||
|
||||
val sortOption: Flow<String?> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { preferences ->
|
||||
preferences[sortOptionKey]
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setSortOption(key: String) {
|
||||
store().edit { preferences ->
|
||||
preferences[sortOptionKey] = key
|
||||
}
|
||||
}
|
||||
|
||||
val libraryItems: Flow<List<SavedLibraryItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { preferences ->
|
||||
|
|
@ -68,8 +54,7 @@ class LibraryPreferences @Inject constructor(
|
|||
saved.id == item.id && saved.type.equals(item.type, ignoreCase = true)
|
||||
} ?: false
|
||||
}
|
||||
val itemWithTimestamp = if (item.addedAt == 0L) item.copy(addedAt = System.currentTimeMillis()) else item
|
||||
preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(itemWithTimestamp)
|
||||
preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import kotlinx.coroutines.launch
|
|||
import kotlin.math.roundToInt
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import com.nuvio.tv.ui.util.languageCodeToName
|
||||
|
||||
/**
|
||||
* Available subtitle languages
|
||||
|
|
@ -29,90 +28,52 @@ data class SubtitleLanguage(
|
|||
val name: String
|
||||
)
|
||||
|
||||
val SubtitleLanguage.displayName: String
|
||||
get() = languageCodeToName(code)
|
||||
|
||||
const val SUBTITLE_LANGUAGE_FORCED = "forced"
|
||||
|
||||
val AVAILABLE_SUBTITLE_LANGUAGES = listOf(
|
||||
SubtitleLanguage("af", "Afrikaans"),
|
||||
SubtitleLanguage("sq", "Albanian"),
|
||||
SubtitleLanguage("am", "Amharic"),
|
||||
SubtitleLanguage("ar", "Arabic"),
|
||||
SubtitleLanguage("hy", "Armenian"),
|
||||
SubtitleLanguage("az", "Azerbaijani"),
|
||||
SubtitleLanguage("eu", "Basque"),
|
||||
SubtitleLanguage("be", "Belarusian"),
|
||||
SubtitleLanguage("bn", "Bengali"),
|
||||
SubtitleLanguage("bs", "Bosnian"),
|
||||
SubtitleLanguage("bg", "Bulgarian"),
|
||||
SubtitleLanguage("my", "Burmese"),
|
||||
SubtitleLanguage("ca", "Catalan"),
|
||||
SubtitleLanguage("zh", "Chinese"),
|
||||
SubtitleLanguage("zh-CN", "Chinese (Simplified)"),
|
||||
SubtitleLanguage("zh-TW", "Chinese (Traditional)"),
|
||||
SubtitleLanguage("hr", "Croatian"),
|
||||
SubtitleLanguage("cs", "Czech"),
|
||||
SubtitleLanguage("da", "Danish"),
|
||||
SubtitleLanguage("nl", "Dutch"),
|
||||
SubtitleLanguage("en", "English"),
|
||||
SubtitleLanguage("et", "Estonian"),
|
||||
SubtitleLanguage("tl", "Filipino"),
|
||||
SubtitleLanguage("fi", "Finnish"),
|
||||
SubtitleLanguage("es", "Spanish"),
|
||||
SubtitleLanguage("fr", "French"),
|
||||
SubtitleLanguage("gl", "Galician"),
|
||||
SubtitleLanguage("ka", "Georgian"),
|
||||
SubtitleLanguage("de", "German"),
|
||||
SubtitleLanguage("el", "Greek"),
|
||||
SubtitleLanguage("gu", "Gujarati"),
|
||||
SubtitleLanguage("he", "Hebrew"),
|
||||
SubtitleLanguage("hi", "Hindi"),
|
||||
SubtitleLanguage("hu", "Hungarian"),
|
||||
SubtitleLanguage("is", "Icelandic"),
|
||||
SubtitleLanguage("id", "Indonesian"),
|
||||
SubtitleLanguage("ga", "Irish"),
|
||||
SubtitleLanguage("it", "Italian"),
|
||||
SubtitleLanguage("ja", "Japanese"),
|
||||
SubtitleLanguage("kn", "Kannada"),
|
||||
SubtitleLanguage("kk", "Kazakh"),
|
||||
SubtitleLanguage("km", "Khmer"),
|
||||
SubtitleLanguage("ko", "Korean"),
|
||||
SubtitleLanguage("lo", "Lao"),
|
||||
SubtitleLanguage("lv", "Latvian"),
|
||||
SubtitleLanguage("lt", "Lithuanian"),
|
||||
SubtitleLanguage("mk", "Macedonian"),
|
||||
SubtitleLanguage("ms", "Malay"),
|
||||
SubtitleLanguage("ml", "Malayalam"),
|
||||
SubtitleLanguage("mt", "Maltese"),
|
||||
SubtitleLanguage("mr", "Marathi"),
|
||||
SubtitleLanguage("mn", "Mongolian"),
|
||||
SubtitleLanguage("ne", "Nepali"),
|
||||
SubtitleLanguage("no", "Norwegian"),
|
||||
SubtitleLanguage("pa", "Punjabi"),
|
||||
SubtitleLanguage("fa", "Persian"),
|
||||
SubtitleLanguage("pl", "Polish"),
|
||||
SubtitleLanguage("pt", "Portuguese (Portugal)"),
|
||||
SubtitleLanguage("pt-br", "Portuguese (Brazil)"),
|
||||
SubtitleLanguage("ro", "Romanian"),
|
||||
SubtitleLanguage("ru", "Russian"),
|
||||
SubtitleLanguage("sr", "Serbian"),
|
||||
SubtitleLanguage("si", "Sinhala"),
|
||||
SubtitleLanguage("ja", "Japanese"),
|
||||
SubtitleLanguage("ko", "Korean"),
|
||||
SubtitleLanguage("zh", "Chinese"),
|
||||
SubtitleLanguage("ar", "Arabic"),
|
||||
SubtitleLanguage("hi", "Hindi"),
|
||||
SubtitleLanguage("tr", "Turkish"),
|
||||
SubtitleLanguage("pl", "Polish"),
|
||||
SubtitleLanguage("nl", "Dutch"),
|
||||
SubtitleLanguage("sv", "Swedish"),
|
||||
SubtitleLanguage("da", "Danish"),
|
||||
SubtitleLanguage("no", "Norwegian"),
|
||||
SubtitleLanguage("fi", "Finnish"),
|
||||
SubtitleLanguage("th", "Thai"),
|
||||
SubtitleLanguage("vi", "Vietnamese"),
|
||||
SubtitleLanguage("id", "Indonesian"),
|
||||
SubtitleLanguage("ms", "Malay"),
|
||||
SubtitleLanguage("he", "Hebrew"),
|
||||
SubtitleLanguage("el", "Greek"),
|
||||
SubtitleLanguage("cs", "Czech"),
|
||||
SubtitleLanguage("hu", "Hungarian"),
|
||||
SubtitleLanguage("ro", "Romanian"),
|
||||
SubtitleLanguage("uk", "Ukrainian"),
|
||||
SubtitleLanguage("bg", "Bulgarian"),
|
||||
SubtitleLanguage("hr", "Croatian"),
|
||||
SubtitleLanguage("sk", "Slovak"),
|
||||
SubtitleLanguage("sl", "Slovenian"),
|
||||
SubtitleLanguage("es", "Spanish"),
|
||||
SubtitleLanguage("es-419", "Spanish (Latin America)"),
|
||||
SubtitleLanguage("sw", "Swahili"),
|
||||
SubtitleLanguage("sv", "Swedish"),
|
||||
SubtitleLanguage("sr", "Serbian"),
|
||||
SubtitleLanguage("ta", "Tamil"),
|
||||
SubtitleLanguage("te", "Telugu"),
|
||||
SubtitleLanguage("th", "Thai"),
|
||||
SubtitleLanguage("tr", "Turkish"),
|
||||
SubtitleLanguage("uk", "Ukrainian"),
|
||||
SubtitleLanguage("ur", "Urdu"),
|
||||
SubtitleLanguage("uz", "Uzbek"),
|
||||
SubtitleLanguage("vi", "Vietnamese"),
|
||||
SubtitleLanguage("cy", "Welsh"),
|
||||
SubtitleLanguage("zu", "Zulu")
|
||||
SubtitleLanguage("ml", "Malayalam"),
|
||||
SubtitleLanguage("bn", "Bengali"),
|
||||
SubtitleLanguage("mr", "Marathi"),
|
||||
SubtitleLanguage("gu", "Gujarati"),
|
||||
SubtitleLanguage("kn", "Kannada"),
|
||||
SubtitleLanguage("pa", "Punjabi")
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -150,7 +111,6 @@ data class BufferSettings(
|
|||
object AudioLanguageOption {
|
||||
const val DEFAULT = "default" // Use media file default
|
||||
const val DEVICE = "device" // Use device locale
|
||||
const val ORIGINAL = "original" // Use content's original language (from TMDB)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -158,8 +118,6 @@ object AudioLanguageOption {
|
|||
*/
|
||||
data class PlayerSettings(
|
||||
val playerPreference: PlayerPreference = PlayerPreference.INTERNAL,
|
||||
val internalPlayerEngine: InternalPlayerEngine = InternalPlayerEngine.EXOPLAYER,
|
||||
val autoSwitchInternalPlayerOnError: Boolean = false,
|
||||
val useLibass: Boolean = false,
|
||||
val libassRenderType: LibassRenderType = LibassRenderType.OVERLAY_OPEN_GL,
|
||||
val subtitleStyle: SubtitleStyleSettings = SubtitleStyleSettings(),
|
||||
|
|
@ -168,21 +126,15 @@ data class PlayerSettings(
|
|||
val decoderPriority: Int = 1, // EXTENSION_RENDERER_MODE_ON (0=off, 1=on, 2=prefer)
|
||||
val tunnelingEnabled: Boolean = false,
|
||||
val skipSilence: Boolean = false,
|
||||
val audioAmplificationDb: Int = 0,
|
||||
val persistAudioAmplification: Boolean = false,
|
||||
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
|
||||
val secondaryPreferredAudioLanguage: String? = null,
|
||||
val loadingOverlayEnabled: Boolean = true,
|
||||
val showPlayerLoadingStatus: Boolean = true,
|
||||
val pauseOverlayEnabled: Boolean = true,
|
||||
val osdClockEnabled: Boolean = true,
|
||||
val skipIntroEnabled: Boolean = true,
|
||||
// Dolby Vision Profile 7 → HEVC fallback (requires forked ExoPlayer)
|
||||
val mapDV7ToHevc: Boolean = false,
|
||||
val mpvHardwareDecodeMode: MpvHardwareDecodeMode = MpvHardwareDecodeMode.AUTO_SAFE,
|
||||
// Display settings
|
||||
val frameRateMatchingMode: FrameRateMatchingMode = FrameRateMatchingMode.OFF,
|
||||
val resolutionMatchingEnabled: Boolean = false,
|
||||
// Stream selection settings
|
||||
val streamAutoPlayMode: StreamAutoPlayMode = StreamAutoPlayMode.MANUAL,
|
||||
val streamAutoPlaySource: StreamAutoPlaySource = StreamAutoPlaySource.ALL_SOURCES,
|
||||
|
|
@ -190,16 +142,13 @@ data class PlayerSettings(
|
|||
val streamAutoPlaySelectedPlugins: Set<String> = emptySet(),
|
||||
val streamAutoPlayRegex: String = "",
|
||||
val streamAutoPlayNextEpisodeEnabled: Boolean = false,
|
||||
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = true,
|
||||
val streamAutoPlayTimeoutSeconds: Int = 3,
|
||||
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = false,
|
||||
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
|
||||
val nextEpisodeThresholdPercent: Float = 99f,
|
||||
val nextEpisodeThresholdPercent: Float = 98f,
|
||||
val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f,
|
||||
val streamReuseLastLinkEnabled: Boolean = false,
|
||||
val streamReuseLastLinkCacheHours: Int = 24,
|
||||
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE,
|
||||
val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES,
|
||||
val resizeMode: Int = 0
|
||||
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE
|
||||
)
|
||||
|
||||
enum class StreamAutoPlayMode {
|
||||
|
|
@ -231,31 +180,12 @@ enum class SubtitleOrganizationMode {
|
|||
BY_ADDON
|
||||
}
|
||||
|
||||
enum class AddonSubtitleStartupMode {
|
||||
FAST_STARTUP,
|
||||
PREFERRED_ONLY,
|
||||
ALL_SUBTITLES
|
||||
}
|
||||
|
||||
enum class MpvHardwareDecodeMode {
|
||||
LEGACY_DIRECT_COPY,
|
||||
AUTO_SAFE,
|
||||
HARDWARE_COPY,
|
||||
HARDWARE_DIRECT,
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum class PlayerPreference {
|
||||
INTERNAL,
|
||||
EXTERNAL,
|
||||
ASK_EVERY_TIME
|
||||
}
|
||||
|
||||
enum class InternalPlayerEngine {
|
||||
EXOPLAYER,
|
||||
MVP_PLAYER
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing the different libass render types
|
||||
* Maps to io.github.peerless2012.ass.media.type.AssRenderType
|
||||
|
|
@ -275,8 +205,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
) {
|
||||
companion object {
|
||||
private const val FEATURE = "player_settings"
|
||||
private const val AUDIO_AMPLIFICATION_DB_MIN = 0
|
||||
private const val AUDIO_AMPLIFICATION_DB_MAX = 10
|
||||
}
|
||||
|
||||
private fun store(profileId: Int = profileManager.activeProfileId.value) =
|
||||
|
|
@ -286,9 +214,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
|
||||
// Player preference key
|
||||
private val playerPreferenceKey = stringPreferencesKey("player_preference")
|
||||
private val internalPlayerEngineKey = stringPreferencesKey("internal_player_engine")
|
||||
private val autoSwitchInternalPlayerOnErrorKey =
|
||||
booleanPreferencesKey("auto_switch_internal_player_on_error")
|
||||
|
||||
// Libass settings keys
|
||||
private val useLibassKey = booleanPreferencesKey("use_libass")
|
||||
|
|
@ -298,20 +223,14 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
private val decoderPriorityKey = intPreferencesKey("decoder_priority")
|
||||
private val tunnelingEnabledKey = booleanPreferencesKey("tunneling_enabled")
|
||||
private val skipSilenceKey = booleanPreferencesKey("skip_silence")
|
||||
private val audioAmplificationDbKey = intPreferencesKey("audio_amplification_db")
|
||||
private val persistAudioAmplificationKey = booleanPreferencesKey("persist_audio_amplification")
|
||||
private val preferredAudioLanguageKey = stringPreferencesKey("preferred_audio_language")
|
||||
private val secondaryPreferredAudioLanguageKey = stringPreferencesKey("secondary_preferred_audio_language")
|
||||
private val loadingOverlayEnabledKey = booleanPreferencesKey("loading_overlay_enabled")
|
||||
private val showPlayerLoadingStatusKey = booleanPreferencesKey("show_player_loading_status")
|
||||
private val pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled")
|
||||
private val osdClockEnabledKey = booleanPreferencesKey("osd_clock_enabled")
|
||||
private val skipIntroEnabledKey = booleanPreferencesKey("skip_intro_enabled")
|
||||
private val mapDV7ToHevcKey = booleanPreferencesKey("map_dv7_to_hevc")
|
||||
private val mpvHardwareDecodeModeKey = stringPreferencesKey("mpv_hardware_decode_mode")
|
||||
private val frameRateMatchingKey = booleanPreferencesKey("frame_rate_matching")
|
||||
private val frameRateMatchingModeKey = stringPreferencesKey("frame_rate_matching_mode")
|
||||
private val resolutionMatchingEnabledKey = booleanPreferencesKey("resolution_matching_enabled")
|
||||
private val streamAutoPlayModeKey = stringPreferencesKey("stream_auto_play_mode")
|
||||
private val streamAutoPlaySourceKey = stringPreferencesKey("stream_auto_play_source")
|
||||
private val streamAutoPlaySelectedAddonsKey = stringSetPreferencesKey("stream_auto_play_selected_addons")
|
||||
|
|
@ -319,7 +238,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
private val streamAutoPlayRegexKey = stringPreferencesKey("stream_auto_play_regex")
|
||||
private val streamAutoPlayNextEpisodeEnabledKey = booleanPreferencesKey("stream_auto_play_next_episode_enabled")
|
||||
private val streamAutoPlayPreferBingeGroupForNextEpisodeKey = booleanPreferencesKey("stream_auto_play_prefer_bingegroup_next_episode")
|
||||
private val streamAutoPlayTimeoutSecondsKey = intPreferencesKey("stream_auto_play_timeout_seconds")
|
||||
private val nextEpisodeThresholdModeKey = stringPreferencesKey("next_episode_threshold_mode")
|
||||
private val nextEpisodeThresholdPercentLegacyKey = intPreferencesKey("next_episode_threshold_percent")
|
||||
private val nextEpisodeThresholdMinutesBeforeEndLegacyKey = intPreferencesKey("next_episode_threshold_minutes_before_end")
|
||||
|
|
@ -328,8 +246,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
private val streamReuseLastLinkEnabledKey = booleanPreferencesKey("stream_reuse_last_link_enabled")
|
||||
private val streamReuseLastLinkCacheHoursKey = intPreferencesKey("stream_reuse_last_link_cache_hours")
|
||||
private val subtitleOrganizationModeKey = stringPreferencesKey("subtitle_organization_mode")
|
||||
private val addonSubtitleStartupModeKey = stringPreferencesKey("addon_subtitle_startup_mode")
|
||||
private val resizeModeKey = intPreferencesKey("resize_mode")
|
||||
|
||||
// Subtitle style settings keys
|
||||
private val subtitlePreferredLanguageKey = stringPreferencesKey("subtitle_preferred_language")
|
||||
|
|
@ -388,19 +304,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
|
||||
if (secondaryPreferredAudioLanguage != null) {
|
||||
val normalizedSecondaryPreferredAudioLanguage =
|
||||
normalizeSecondaryAudioLanguageCode(secondaryPreferredAudioLanguage)
|
||||
if (normalizedSecondaryPreferredAudioLanguage != secondaryPreferredAudioLanguage) {
|
||||
if (normalizedSecondaryPreferredAudioLanguage != null) {
|
||||
prefs[secondaryPreferredAudioLanguageKey] = normalizedSecondaryPreferredAudioLanguage
|
||||
} else {
|
||||
prefs.remove(secondaryPreferredAudioLanguageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val preferredSubtitleLanguage = prefs[subtitlePreferredLanguageKey]
|
||||
if (preferredSubtitleLanguage != null) {
|
||||
val normalizedPreferredSubtitleLanguage =
|
||||
|
|
@ -431,10 +334,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
playerPreference = prefs[playerPreferenceKey]?.let {
|
||||
runCatching { PlayerPreference.valueOf(it) }.getOrDefault(PlayerPreference.INTERNAL)
|
||||
} ?: PlayerPreference.INTERNAL,
|
||||
internalPlayerEngine = prefs[internalPlayerEngineKey]?.let {
|
||||
runCatching { InternalPlayerEngine.valueOf(it) }.getOrDefault(InternalPlayerEngine.EXOPLAYER)
|
||||
} ?: InternalPlayerEngine.EXOPLAYER,
|
||||
autoSwitchInternalPlayerOnError = prefs[autoSwitchInternalPlayerOnErrorKey] ?: false,
|
||||
useLibass = prefs[useLibassKey] ?: false,
|
||||
libassRenderType = prefs[libassRenderTypeKey]?.let {
|
||||
try { LibassRenderType.valueOf(it) } catch (e: Exception) { LibassRenderType.OVERLAY_OPEN_GL }
|
||||
|
|
@ -442,23 +341,14 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
decoderPriority = prefs[decoderPriorityKey] ?: 1,
|
||||
tunnelingEnabled = prefs[tunnelingEnabledKey] ?: false,
|
||||
skipSilence = prefs[skipSilenceKey] ?: false,
|
||||
audioAmplificationDb = (prefs[audioAmplificationDbKey] ?: 0).coerceIn(
|
||||
AUDIO_AMPLIFICATION_DB_MIN,
|
||||
AUDIO_AMPLIFICATION_DB_MAX
|
||||
),
|
||||
persistAudioAmplification = prefs[persistAudioAmplificationKey] ?: false,
|
||||
preferredAudioLanguage = normalizeSelectableLanguageCode(
|
||||
prefs[preferredAudioLanguageKey] ?: AudioLanguageOption.DEVICE
|
||||
),
|
||||
secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
|
||||
?.let(::normalizeSecondaryAudioLanguageCode),
|
||||
loadingOverlayEnabled = prefs[loadingOverlayEnabledKey] ?: true,
|
||||
showPlayerLoadingStatus = prefs[showPlayerLoadingStatusKey] ?: true,
|
||||
pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true,
|
||||
osdClockEnabled = prefs[osdClockEnabledKey] ?: true,
|
||||
skipIntroEnabled = prefs[skipIntroEnabledKey] ?: true,
|
||||
mapDV7ToHevc = prefs[mapDV7ToHevcKey] ?: false,
|
||||
mpvHardwareDecodeMode = parseMpvHardwareDecodeMode(prefs[mpvHardwareDecodeModeKey]),
|
||||
frameRateMatchingMode = prefs[frameRateMatchingModeKey]?.let {
|
||||
runCatching { FrameRateMatchingMode.valueOf(it) }.getOrNull()
|
||||
} ?: if (prefs[frameRateMatchingKey] == true) {
|
||||
|
|
@ -466,7 +356,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
} else {
|
||||
FrameRateMatchingMode.OFF
|
||||
},
|
||||
resolutionMatchingEnabled = prefs[resolutionMatchingEnabledKey] ?: false,
|
||||
streamAutoPlayMode = prefs[streamAutoPlayModeKey]?.let {
|
||||
runCatching { StreamAutoPlayMode.valueOf(it) }.getOrDefault(StreamAutoPlayMode.MANUAL)
|
||||
} ?: StreamAutoPlayMode.MANUAL,
|
||||
|
|
@ -478,15 +367,14 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
streamAutoPlayRegex = prefs[streamAutoPlayRegexKey] ?: "",
|
||||
streamAutoPlayNextEpisodeEnabled = prefs[streamAutoPlayNextEpisodeEnabledKey] ?: false,
|
||||
streamAutoPlayPreferBingeGroupForNextEpisode =
|
||||
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: true,
|
||||
streamAutoPlayTimeoutSeconds = (prefs[streamAutoPlayTimeoutSecondsKey] ?: 3).coerceIn(0, 11),
|
||||
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: false,
|
||||
nextEpisodeThresholdMode = prefs[nextEpisodeThresholdModeKey]?.let {
|
||||
runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrDefault(NextEpisodeThresholdMode.PERCENTAGE)
|
||||
} ?: NextEpisodeThresholdMode.PERCENTAGE,
|
||||
nextEpisodeThresholdPercent = normalizeHalfStep(
|
||||
value = prefs[nextEpisodeThresholdPercentKey]
|
||||
?: prefs[nextEpisodeThresholdPercentLegacyKey]?.toFloat()
|
||||
?: 99f,
|
||||
?: 98f,
|
||||
min = 97f,
|
||||
max = 99.5f
|
||||
),
|
||||
|
|
@ -500,8 +388,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
streamReuseLastLinkEnabled = prefs[streamReuseLastLinkEnabledKey] ?: false,
|
||||
streamReuseLastLinkCacheHours = (prefs[streamReuseLastLinkCacheHoursKey] ?: 24).coerceIn(1, 168),
|
||||
subtitleOrganizationMode = parseSubtitleOrganizationMode(prefs[subtitleOrganizationModeKey]),
|
||||
addonSubtitleStartupMode = parseAddonSubtitleStartupMode(prefs[addonSubtitleStartupModeKey]),
|
||||
resizeMode = (prefs[resizeModeKey] ?: 0).coerceIn(0, 4),
|
||||
subtitleStyle = SubtitleStyleSettings(
|
||||
preferredLanguage = normalizeSelectableLanguageCode(
|
||||
prefs[subtitlePreferredLanguageKey] ?: "en"
|
||||
|
|
@ -558,18 +444,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setInternalPlayerEngine(engine: InternalPlayerEngine) {
|
||||
store().edit { prefs ->
|
||||
prefs[internalPlayerEngineKey] = engine.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoSwitchInternalPlayerOnError(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[autoSwitchInternalPlayerOnErrorKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// Audio settings setters
|
||||
|
||||
suspend fun setDecoderPriority(priority: Int) {
|
||||
|
|
@ -590,27 +464,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setAudioAmplificationDb(db: Int) {
|
||||
store().edit { prefs ->
|
||||
prefs[audioAmplificationDbKey] = db.coerceIn(
|
||||
AUDIO_AMPLIFICATION_DB_MIN,
|
||||
AUDIO_AMPLIFICATION_DB_MAX
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPersistAudioAmplification(enabled: Boolean, dbToPersist: Int? = null) {
|
||||
store().edit { prefs ->
|
||||
prefs[persistAudioAmplificationKey] = enabled
|
||||
if (enabled && dbToPersist != null) {
|
||||
prefs[audioAmplificationDbKey] = dbToPersist.coerceIn(
|
||||
AUDIO_AMPLIFICATION_DB_MIN,
|
||||
AUDIO_AMPLIFICATION_DB_MAX
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPreferredAudioLanguage(language: String) {
|
||||
store().edit { prefs ->
|
||||
prefs[preferredAudioLanguageKey] = normalizeSelectableLanguageCode(
|
||||
|
|
@ -619,19 +472,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setSecondaryPreferredAudioLanguage(language: String?) {
|
||||
store().edit { prefs ->
|
||||
val normalizedLanguage = language
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::normalizeSecondaryAudioLanguageCode)
|
||||
if (normalizedLanguage != null) {
|
||||
prefs[secondaryPreferredAudioLanguageKey] = normalizedLanguage
|
||||
} else {
|
||||
prefs.remove(secondaryPreferredAudioLanguageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPauseOverlayEnabled(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[pauseOverlayEnabledKey] = enabled
|
||||
|
|
@ -656,12 +496,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setShowPlayerLoadingStatus(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[showPlayerLoadingStatusKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setFrameRateMatchingMode(mode: FrameRateMatchingMode) {
|
||||
store().edit { prefs ->
|
||||
prefs[frameRateMatchingModeKey] = mode.name
|
||||
|
|
@ -669,12 +503,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setResolutionMatchingEnabled(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[resolutionMatchingEnabledKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setFrameRateMatching(enabled: Boolean) {
|
||||
setFrameRateMatchingMode(
|
||||
if (enabled) FrameRateMatchingMode.START_STOP else FrameRateMatchingMode.OFF
|
||||
|
|
@ -723,12 +551,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setStreamAutoPlayTimeoutSeconds(seconds: Int) {
|
||||
store().edit { prefs ->
|
||||
prefs[streamAutoPlayTimeoutSecondsKey] = seconds.coerceIn(0, 11)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setNextEpisodeThresholdMode(mode: NextEpisodeThresholdMode) {
|
||||
store().edit { prefs ->
|
||||
prefs[nextEpisodeThresholdModeKey] = mode.name
|
||||
|
|
@ -778,18 +600,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setAddonSubtitleStartupMode(mode: AddonSubtitleStartupMode) {
|
||||
store().edit { prefs ->
|
||||
prefs[addonSubtitleStartupModeKey] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setResizeMode(mode: Int) {
|
||||
store().edit { prefs ->
|
||||
prefs[resizeModeKey] = mode.coerceIn(0, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubtitleOrganizationMode(value: String?): SubtitleOrganizationMode {
|
||||
return when (value) {
|
||||
null, "NONE" -> SubtitleOrganizationMode.NONE
|
||||
|
|
@ -799,26 +609,6 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun parseAddonSubtitleStartupMode(value: String?): AddonSubtitleStartupMode {
|
||||
return when (value) {
|
||||
null, "ALL_SUBTITLES" -> AddonSubtitleStartupMode.ALL_SUBTITLES
|
||||
"PREFERRED_ONLY" -> AddonSubtitleStartupMode.PREFERRED_ONLY
|
||||
"FAST_STARTUP" -> AddonSubtitleStartupMode.FAST_STARTUP
|
||||
else -> AddonSubtitleStartupMode.ALL_SUBTITLES
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMpvHardwareDecodeMode(value: String?): MpvHardwareDecodeMode {
|
||||
return when (value) {
|
||||
null, "AUTO_SAFE" -> MpvHardwareDecodeMode.AUTO_SAFE
|
||||
"HARDWARE_COPY" -> MpvHardwareDecodeMode.HARDWARE_COPY
|
||||
"HARDWARE_DIRECT" -> MpvHardwareDecodeMode.HARDWARE_DIRECT
|
||||
"DISABLED" -> MpvHardwareDecodeMode.DISABLED
|
||||
"LEGACY_DIRECT_COPY" -> MpvHardwareDecodeMode.LEGACY_DIRECT_COPY
|
||||
else -> MpvHardwareDecodeMode.AUTO_SAFE
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeSelectableLanguageCode(language: String): String {
|
||||
val code = language.trim().lowercase()
|
||||
return when (code) {
|
||||
|
|
@ -829,29 +619,12 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun normalizeSecondaryAudioLanguageCode(language: String): String? {
|
||||
val normalized = normalizeSelectableLanguageCode(language)
|
||||
return when (normalized) {
|
||||
AudioLanguageOption.DEFAULT,
|
||||
AudioLanguageOption.DEVICE,
|
||||
AudioLanguageOption.ORIGINAL,
|
||||
SUBTITLE_LANGUAGE_FORCED -> null
|
||||
else -> normalized
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setMapDV7ToHevc(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[mapDV7ToHevcKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setMpvHardwareDecodeMode(mode: MpvHardwareDecodeMode) {
|
||||
store().edit { prefs ->
|
||||
prefs[mpvHardwareDecodeModeKey] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to use libass for ASS/SSA subtitle rendering
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -67,13 +67,9 @@ class PluginDataStore @Inject constructor(
|
|||
get() {
|
||||
val pid = effectiveProfileId()
|
||||
val dirName = if (pid == 1) "plugin_code" else "plugin_code_p${pid}"
|
||||
return File(context.filesDir, dirName)
|
||||
return File(context.filesDir, dirName).also { it.mkdirs() }
|
||||
}
|
||||
|
||||
private suspend fun ensureCodeDir(): File = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
codeDir.also { it.mkdirs() }
|
||||
}
|
||||
|
||||
// Repositories
|
||||
val repositories: Flow<List<PluginRepository>> = effectiveProfileIdFlow.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { prefs ->
|
||||
|
|
@ -174,30 +170,21 @@ class PluginDataStore @Inject constructor(
|
|||
return File(codeDir, "$scraperId.js")
|
||||
}
|
||||
|
||||
suspend fun saveScraperCode(scraperId: String, code: String) {
|
||||
val dir = ensureCodeDir()
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
File(dir, "$scraperId.js").writeText(code)
|
||||
}
|
||||
fun saveScraperCode(scraperId: String, code: String) {
|
||||
getScraperCodeFile(scraperId).writeText(code)
|
||||
}
|
||||
|
||||
suspend fun getScraperCode(scraperId: String): String? {
|
||||
return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val file = File(codeDir, "$scraperId.js")
|
||||
if (file.exists()) file.readText() else null
|
||||
}
|
||||
fun getScraperCode(scraperId: String): String? {
|
||||
val file = getScraperCodeFile(scraperId)
|
||||
return if (file.exists()) file.readText() else null
|
||||
}
|
||||
|
||||
suspend fun deleteScraperCode(scraperId: String) {
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
File(codeDir, "$scraperId.js").delete()
|
||||
}
|
||||
fun deleteScraperCode(scraperId: String) {
|
||||
getScraperCodeFile(scraperId).delete()
|
||||
}
|
||||
|
||||
suspend fun clearAllScraperCode() {
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
codeDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
fun clearAllScraperCode() {
|
||||
codeDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
// Per-scraper settings
|
||||
|
|
|
|||
|
|
@ -120,16 +120,14 @@ internal data class ProfileJson(
|
|||
val name: String,
|
||||
val avatarColorHex: String,
|
||||
val usesPrimaryAddons: Boolean = false,
|
||||
val usesPrimaryPlugins: Boolean = false,
|
||||
val avatarId: String? = null
|
||||
val usesPrimaryPlugins: Boolean = false
|
||||
) {
|
||||
fun toDomain() = UserProfile(
|
||||
id = id,
|
||||
name = name,
|
||||
avatarColorHex = avatarColorHex,
|
||||
usesPrimaryAddons = usesPrimaryAddons,
|
||||
usesPrimaryPlugins = usesPrimaryPlugins,
|
||||
avatarId = avatarId
|
||||
usesPrimaryPlugins = usesPrimaryPlugins
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
|
@ -138,8 +136,7 @@ internal data class ProfileJson(
|
|||
name = profile.name,
|
||||
avatarColorHex = profile.avatarColorHex,
|
||||
usesPrimaryAddons = profile.usesPrimaryAddons,
|
||||
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
||||
avatarId = profile.avatarId
|
||||
usesPrimaryPlugins = profile.usesPrimaryPlugins
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
|||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
|
@ -16,17 +15,9 @@ class ProfileDataStoreFactory @Inject constructor(
|
|||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val cache = ConcurrentHashMap<String, DataStore<Preferences>>()
|
||||
private val deletedProfileIds = ConcurrentHashMap.newKeySet<Int>()
|
||||
|
||||
fun get(profileId: Int, featureName: String): DataStore<Preferences> {
|
||||
val fileName = if (profileId == 1) featureName else "${featureName}_p${profileId}"
|
||||
if (profileId != 1 && profileId in deletedProfileIds) {
|
||||
return cache.compute(fileName) { _, _ ->
|
||||
PreferenceDataStoreFactory.create {
|
||||
context.preferencesDataStoreFile(fileName)
|
||||
}
|
||||
}!!
|
||||
}
|
||||
return cache.getOrPut(fileName) {
|
||||
PreferenceDataStoreFactory.create {
|
||||
context.preferencesDataStoreFile(fileName)
|
||||
|
|
@ -34,23 +25,10 @@ class ProfileDataStoreFactory @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun clearProfile(profileId: Int) {
|
||||
fun clearProfile(profileId: Int) {
|
||||
if (profileId == 1) return
|
||||
deletedProfileIds.add(profileId)
|
||||
val suffix = "_p${profileId}"
|
||||
val keysToRemove = cache.keys.filter { key -> key.endsWith(suffix) }
|
||||
for (key in keysToRemove) {
|
||||
val store = cache[key]
|
||||
if (store != null) {
|
||||
runCatching { store.edit { it.clear() } }
|
||||
}
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun isProfileDeleted(profileId: Int): Boolean = profileId in deletedProfileIds
|
||||
|
||||
fun markProfileCreated(profileId: Int) {
|
||||
deletedProfileIds.remove(profileId)
|
||||
keysToRemove.forEach { key -> cache.remove(key) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import androidx.datastore.preferences.core.edit
|
|||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
import javax.inject.Inject
|
||||
|
|
@ -16,13 +15,8 @@ data class CachedStreamLink(
|
|||
val streamName: String,
|
||||
val headers: Map<String, String>,
|
||||
val cachedAtMs: Long,
|
||||
val filename: String? = null,
|
||||
val videoHash: String? = null,
|
||||
val videoSize: Long? = null,
|
||||
val infoHash: String? = null,
|
||||
val fileIdx: Int? = null,
|
||||
val sources: List<String>? = null,
|
||||
val bingeGroup: String? = null
|
||||
val rememberedAudioLanguage: String? = null,
|
||||
val rememberedAudioName: String? = null
|
||||
)
|
||||
|
||||
@Singleton
|
||||
|
|
@ -42,26 +36,16 @@ class StreamLinkCacheDataStore @Inject constructor(
|
|||
url: String,
|
||||
streamName: String,
|
||||
headers: Map<String, String>?,
|
||||
filename: String? = null,
|
||||
videoHash: String? = null,
|
||||
videoSize: Long? = null,
|
||||
infoHash: String? = null,
|
||||
fileIdx: Int? = null,
|
||||
sources: List<String>? = null,
|
||||
bingeGroup: String? = null
|
||||
rememberedAudioLanguage: String? = null,
|
||||
rememberedAudioName: String? = null
|
||||
) {
|
||||
val payload = JSONObject().apply {
|
||||
put("url", url)
|
||||
put("streamName", streamName)
|
||||
put("cachedAtMs", System.currentTimeMillis())
|
||||
put("headers", JSONObject(headers ?: emptyMap<String, String>()))
|
||||
put("filename", filename)
|
||||
put("videoHash", videoHash)
|
||||
videoSize?.let { put("videoSize", it) }
|
||||
infoHash?.let { put("infoHash", it) }
|
||||
fileIdx?.let { put("fileIdx", it) }
|
||||
sources?.let { put("sources", JSONArray(it)) }
|
||||
bingeGroup?.let { put("bingeGroup", it) }
|
||||
put("rememberedAudioLanguage", rememberedAudioLanguage)
|
||||
put("rememberedAudioName", rememberedAudioName)
|
||||
}.toString()
|
||||
|
||||
store().edit { prefs ->
|
||||
|
|
@ -90,30 +74,15 @@ class StreamLinkCacheDataStore @Inject constructor(
|
|||
|
||||
val url = json.optString("url", "")
|
||||
val streamName = json.optString("streamName", "")
|
||||
val infoHash = json.optString("infoHash", "").ifBlank { null }
|
||||
// Accept entry if it has either a real URL (HTTP stream) or an
|
||||
// infoHash (torrent stream — URL is re-resolved on each playback).
|
||||
if (streamName.isBlank() || (url.isBlank() && infoHash == null)) return@runCatching null
|
||||
|
||||
val sourcesJson = json.optJSONArray("sources")
|
||||
val sources = sourcesJson?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optString(i).takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}?.takeIf { it.isNotEmpty() }
|
||||
if (url.isBlank() || streamName.isBlank()) return@runCatching null
|
||||
|
||||
CachedStreamLink(
|
||||
url = url,
|
||||
streamName = streamName,
|
||||
headers = headers,
|
||||
cachedAtMs = cachedAtMs,
|
||||
filename = json.optString("filename", "").ifBlank { null },
|
||||
videoHash = json.optString("videoHash", "").ifBlank { null },
|
||||
videoSize = json.optLong("videoSize", -1L).takeIf { it >= 0L },
|
||||
infoHash = infoHash,
|
||||
fileIdx = if (json.has("fileIdx")) json.optInt("fileIdx", -1).takeIf { it >= 0 } else null,
|
||||
sources = sources,
|
||||
bingeGroup = json.optString("bingeGroup", "").ifBlank { null }
|
||||
rememberedAudioLanguage = json.optString("rememberedAudioLanguage", "").ifBlank { null },
|
||||
rememberedAudioName = json.optString("rememberedAudioName", "").ifBlank { null }
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.nuvio.tv.data.local
|
|||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.nuvio.tv.domain.model.AppFont
|
||||
import com.nuvio.tv.domain.model.AppTheme
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
|
@ -24,7 +23,6 @@ class ThemeDataStore @Inject constructor(
|
|||
factory.get(profileId, FEATURE)
|
||||
|
||||
private val themeKey = stringPreferencesKey("selected_theme")
|
||||
private val fontKey = stringPreferencesKey("selected_font")
|
||||
|
||||
val selectedTheme: Flow<AppTheme> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { prefs ->
|
||||
|
|
@ -37,26 +35,9 @@ class ThemeDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val selectedFont: Flow<AppFont> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { prefs ->
|
||||
val fontName = prefs[fontKey] ?: AppFont.INTER.name
|
||||
try {
|
||||
AppFont.valueOf(fontName)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
AppFont.INTER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setTheme(theme: AppTheme) {
|
||||
store().edit { prefs ->
|
||||
prefs[themeKey] = theme.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setFont(font: AppFont) {
|
||||
store().edit { prefs ->
|
||||
prefs[fontKey] = font.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,37 +24,29 @@ class TmdbSettingsDataStore @Inject constructor(
|
|||
factory.get(profileId, FEATURE)
|
||||
|
||||
private val enabledKey = booleanPreferencesKey("tmdb_enabled")
|
||||
private val modernHomeEnabledKey = booleanPreferencesKey("tmdb_modern_home_enabled")
|
||||
private val enrichContinueWatchingKey = booleanPreferencesKey("tmdb_enrich_continue_watching")
|
||||
private val languageKey = stringPreferencesKey("tmdb_language")
|
||||
private val useArtworkKey = booleanPreferencesKey("tmdb_use_artwork")
|
||||
private val useBasicInfoKey = booleanPreferencesKey("tmdb_use_basic_info")
|
||||
private val useDetailsKey = booleanPreferencesKey("tmdb_use_details")
|
||||
private val useReleaseDatesKey = booleanPreferencesKey("tmdb_use_release_dates")
|
||||
private val useCreditsKey = booleanPreferencesKey("tmdb_use_credits")
|
||||
private val useProductionsKey = booleanPreferencesKey("tmdb_use_productions")
|
||||
private val useNetworksKey = booleanPreferencesKey("tmdb_use_networks")
|
||||
private val useEpisodesKey = booleanPreferencesKey("tmdb_use_episodes")
|
||||
private val useMoreLikeThisKey = booleanPreferencesKey("tmdb_use_more_like_this")
|
||||
private val useCollectionsKey = booleanPreferencesKey("tmdb_use_collections")
|
||||
|
||||
val settings: Flow<TmdbSettings> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { prefs ->
|
||||
TmdbSettings(
|
||||
enabled = prefs[enabledKey] ?: false,
|
||||
modernHomeEnabled = prefs[modernHomeEnabledKey] ?: false,
|
||||
enrichContinueWatching = prefs[enrichContinueWatchingKey] ?: true,
|
||||
language = prefs[languageKey] ?: "en",
|
||||
useArtwork = prefs[useArtworkKey] ?: true,
|
||||
useBasicInfo = prefs[useBasicInfoKey] ?: true,
|
||||
useDetails = prefs[useDetailsKey] ?: true,
|
||||
useReleaseDates = prefs[useReleaseDatesKey] ?: true,
|
||||
useCredits = prefs[useCreditsKey] ?: true,
|
||||
useProductions = prefs[useProductionsKey] ?: true,
|
||||
useNetworks = prefs[useNetworksKey] ?: true,
|
||||
useEpisodes = prefs[useEpisodesKey] ?: true,
|
||||
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true,
|
||||
useCollections = prefs[useCollectionsKey] ?: true
|
||||
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -63,14 +55,6 @@ class TmdbSettingsDataStore @Inject constructor(
|
|||
store().edit { it[enabledKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setModernHomeEnabled(enabled: Boolean) {
|
||||
store().edit { it[modernHomeEnabledKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setEnrichContinueWatching(enabled: Boolean) {
|
||||
store().edit { it[enrichContinueWatchingKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setLanguage(language: String) {
|
||||
store().edit { it[languageKey] = language.ifBlank { "en" } }
|
||||
}
|
||||
|
|
@ -87,10 +71,6 @@ class TmdbSettingsDataStore @Inject constructor(
|
|||
store().edit { it[useDetailsKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setUseReleaseDates(enabled: Boolean) {
|
||||
store().edit { it[useReleaseDatesKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setUseCredits(enabled: Boolean) {
|
||||
store().edit { it[useCreditsKey] = enabled }
|
||||
}
|
||||
|
|
@ -110,8 +90,4 @@ class TmdbSettingsDataStore @Inject constructor(
|
|||
suspend fun setUseMoreLikeThis(enabled: Boolean) {
|
||||
store().edit { it[useMoreLikeThisKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setUseCollections(enabled: Boolean) {
|
||||
store().edit { it[useCollectionsKey] = enabled }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ class TrailerSettingsDataStore @Inject constructor(
|
|||
val settings: Flow<TrailerSettings> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { prefs ->
|
||||
TrailerSettings(
|
||||
enabled = prefs[enabledKey] ?: true,
|
||||
enabled = prefs[enabledKey] ?: false,
|
||||
delaySeconds = prefs[delaySecondsKey] ?: 7
|
||||
)
|
||||
}
|
||||
|
|
@ -44,6 +44,6 @@ class TrailerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
|
||||
data class TrailerSettings(
|
||||
val enabled: Boolean = true,
|
||||
val enabled: Boolean = false,
|
||||
val delaySeconds: Int = 7
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
package com.nuvio.tv.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.nuvio.tv.data.remote.dto.trakt.TraktDeviceCodeResponseDto
|
||||
import com.nuvio.tv.data.remote.dto.trakt.TraktTokenResponseDto
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS = 86_400
|
||||
|
||||
internal fun normalizeTraktTokenLifetimeSeconds(expiresIn: Int): Int {
|
||||
if (expiresIn <= 0) return TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS
|
||||
return expiresIn.coerceAtMost(TRAKT_ACCESS_TOKEN_MAX_LIFETIME_SECONDS)
|
||||
}
|
||||
private val Context.traktAuthDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "trakt_auth_store"
|
||||
)
|
||||
|
||||
data class TraktAuthState(
|
||||
val accessToken: String? = null,
|
||||
|
|
@ -40,15 +41,10 @@ data class TraktAuthState(
|
|||
}
|
||||
|
||||
@Singleton
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TraktAuthDataStore @Inject constructor(
|
||||
private val factory: ProfileDataStoreFactory,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val profileManager: ProfileManager
|
||||
) {
|
||||
companion object {
|
||||
private const val FEATURE = "trakt_auth_store"
|
||||
}
|
||||
|
||||
private val accessTokenKey = stringPreferencesKey("access_token")
|
||||
private val refreshTokenKey = stringPreferencesKey("refresh_token")
|
||||
private val tokenTypeKey = stringPreferencesKey("token_type")
|
||||
|
|
@ -64,44 +60,44 @@ class TraktAuthDataStore @Inject constructor(
|
|||
private val expiresAtKey = longPreferencesKey("expires_at")
|
||||
private val pollIntervalKey = intPreferencesKey("poll_interval")
|
||||
|
||||
private fun store(profileId: Int = profileManager.activeProfileId.value) =
|
||||
factory.get(profileId, FEATURE)
|
||||
|
||||
val state: Flow<TraktAuthState> = profileManager.activeProfileId.flatMapLatest { profileId ->
|
||||
store(profileId).data.map { preferences ->
|
||||
TraktAuthState(
|
||||
accessToken = preferences[accessTokenKey],
|
||||
refreshToken = preferences[refreshTokenKey],
|
||||
tokenType = preferences[tokenTypeKey],
|
||||
createdAt = preferences[createdAtKey],
|
||||
expiresIn = preferences[expiresInKey]?.let(::normalizeTraktTokenLifetimeSeconds),
|
||||
username = preferences[usernameKey],
|
||||
userSlug = preferences[userSlugKey],
|
||||
deviceCode = preferences[deviceCodeKey],
|
||||
userCode = preferences[userCodeKey],
|
||||
verificationUrl = preferences[verificationUrlKey],
|
||||
expiresAt = preferences[expiresAtKey],
|
||||
pollInterval = preferences[pollIntervalKey]
|
||||
)
|
||||
}
|
||||
val state: Flow<TraktAuthState> = context.traktAuthDataStore.data.map { preferences ->
|
||||
TraktAuthState(
|
||||
accessToken = preferences[accessTokenKey],
|
||||
refreshToken = preferences[refreshTokenKey],
|
||||
tokenType = preferences[tokenTypeKey],
|
||||
createdAt = preferences[createdAtKey],
|
||||
expiresIn = preferences[expiresInKey],
|
||||
username = preferences[usernameKey],
|
||||
userSlug = preferences[userSlugKey],
|
||||
deviceCode = preferences[deviceCodeKey],
|
||||
userCode = preferences[userCodeKey],
|
||||
verificationUrl = preferences[verificationUrlKey],
|
||||
expiresAt = preferences[expiresAtKey],
|
||||
pollInterval = preferences[pollIntervalKey]
|
||||
)
|
||||
}
|
||||
|
||||
val isAuthenticated: Flow<Boolean> = state.map { it.isAuthenticated }
|
||||
|
||||
val isEffectivelyAuthenticated: Flow<Boolean> = isAuthenticated
|
||||
val isEffectivelyAuthenticated: Flow<Boolean> = combine(
|
||||
isAuthenticated,
|
||||
profileManager.activeProfileId
|
||||
) { authenticated, profileId ->
|
||||
authenticated && profileId == 1
|
||||
}
|
||||
|
||||
suspend fun saveToken(token: TraktTokenResponseDto) {
|
||||
store().edit { preferences ->
|
||||
context.traktAuthDataStore.edit { preferences ->
|
||||
preferences[accessTokenKey] = token.accessToken
|
||||
preferences[refreshTokenKey] = token.refreshToken
|
||||
preferences[tokenTypeKey] = token.tokenType
|
||||
preferences[createdAtKey] = token.createdAt
|
||||
preferences[expiresInKey] = normalizeTraktTokenLifetimeSeconds(token.expiresIn)
|
||||
preferences[expiresInKey] = token.expiresIn
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveUser(username: String?, userSlug: String?) {
|
||||
store().edit { preferences ->
|
||||
context.traktAuthDataStore.edit { preferences ->
|
||||
if (username.isNullOrBlank()) {
|
||||
preferences.remove(usernameKey)
|
||||
} else {
|
||||
|
|
@ -117,7 +113,7 @@ class TraktAuthDataStore @Inject constructor(
|
|||
|
||||
suspend fun saveDeviceFlow(data: TraktDeviceCodeResponseDto) {
|
||||
val now = System.currentTimeMillis()
|
||||
store().edit { preferences ->
|
||||
context.traktAuthDataStore.edit { preferences ->
|
||||
preferences[deviceCodeKey] = data.deviceCode
|
||||
preferences[userCodeKey] = data.userCode
|
||||
preferences[verificationUrlKey] = data.verificationUrl
|
||||
|
|
@ -127,13 +123,13 @@ class TraktAuthDataStore @Inject constructor(
|
|||
}
|
||||
|
||||
suspend fun updatePollInterval(seconds: Int) {
|
||||
store().edit { preferences ->
|
||||
context.traktAuthDataStore.edit { preferences ->
|
||||
preferences[pollIntervalKey] = seconds
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearDeviceFlow() {
|
||||
store().edit { preferences ->
|
||||
context.traktAuthDataStore.edit { preferences ->
|
||||
preferences.remove(deviceCodeKey)
|
||||
preferences.remove(userCodeKey)
|
||||
preferences.remove(verificationUrlKey)
|
||||
|
|
@ -143,7 +139,7 @@ class TraktAuthDataStore @Inject constructor(
|
|||
}
|
||||
|
||||
suspend fun clearAuth() {
|
||||
store().edit { preferences ->
|
||||
context.traktAuthDataStore.edit { preferences ->
|
||||
preferences.remove(accessTokenKey)
|
||||
preferences.remove(refreshTokenKey)
|
||||
preferences.remove(tokenTypeKey)
|
||||
|
|
@ -159,3 +155,4 @@ class TraktAuthDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,29 +3,15 @@ package com.nuvio.tv.data.local
|
|||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class WatchProgressSource {
|
||||
TRAKT,
|
||||
NUVIO_SYNC;
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): WatchProgressSource {
|
||||
return entries.firstOrNull { it.name == value } ?: TRAKT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TraktSettingsDataStore @Inject constructor(
|
||||
private val factory: ProfileDataStoreFactory,
|
||||
private val profileManager: ProfileManager
|
||||
|
|
@ -35,8 +21,6 @@ class TraktSettingsDataStore @Inject constructor(
|
|||
const val CONTINUE_WATCHING_DAYS_CAP_ALL = 0
|
||||
const val DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60
|
||||
const val DEFAULT_SHOW_UNAIRED_NEXT_UP = true
|
||||
const val DEFAULT_SHOW_META_COMMENTS = true
|
||||
val DEFAULT_WATCH_PROGRESS_SOURCE = WatchProgressSource.TRAKT
|
||||
const val MIN_CONTINUE_WATCHING_DAYS_CAP = 7
|
||||
const val MAX_CONTINUE_WATCHING_DAYS_CAP = 365
|
||||
}
|
||||
|
|
@ -47,8 +31,6 @@ class TraktSettingsDataStore @Inject constructor(
|
|||
private val continueWatchingDaysCapKey = intPreferencesKey("continue_watching_days_cap")
|
||||
private val dismissedNextUpKeysKey = stringSetPreferencesKey("dismissed_next_up_keys")
|
||||
private val showUnairedNextUpKey = booleanPreferencesKey("show_unaired_next_up")
|
||||
private val showMetaCommentsKey = booleanPreferencesKey("show_meta_comments")
|
||||
private val watchProgressSourceKey = stringPreferencesKey("watch_progress_source")
|
||||
|
||||
val continueWatchingDaysCap: Flow<Int> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { prefs ->
|
||||
|
|
@ -70,18 +52,6 @@ class TraktSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val showMetaComments: Flow<Boolean> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { prefs ->
|
||||
prefs[showMetaCommentsKey] ?: DEFAULT_SHOW_META_COMMENTS
|
||||
}
|
||||
}
|
||||
|
||||
val watchProgressSource: Flow<WatchProgressSource> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { prefs ->
|
||||
WatchProgressSource.fromStorage(prefs[watchProgressSourceKey])
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setContinueWatchingDaysCap(days: Int) {
|
||||
store().edit { prefs ->
|
||||
prefs[continueWatchingDaysCapKey] = normalizeContinueWatchingDaysCap(days)
|
||||
|
|
@ -104,33 +74,9 @@ class TraktSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun removeDismissedNextUpKeysForContent(contentId: String) {
|
||||
if (contentId.isBlank()) return
|
||||
val prefix = "${contentId.trim()}|"
|
||||
store().edit { prefs ->
|
||||
val current = prefs[dismissedNextUpKeysKey] ?: emptySet()
|
||||
val filtered = current.filterNot { it.startsWith(prefix) }
|
||||
if (filtered.size != current.size) {
|
||||
prefs[dismissedNextUpKeysKey] = filtered.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setShowUnairedNextUp(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[showUnairedNextUpKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setShowMetaComments(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[showMetaCommentsKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setWatchProgressSource(source: WatchProgressSource) {
|
||||
store().edit { prefs ->
|
||||
prefs[watchProgressSourceKey] = source.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ class WatchProgressPreferences @Inject constructor(
|
|||
private val gson = Gson()
|
||||
private val watchProgressKey = stringPreferencesKey("watch_progress_map")
|
||||
|
||||
// Maximum items to keep in continue watching
|
||||
private val maxItems = 50
|
||||
|
||||
private val maxEpisodesPerContent = 25
|
||||
|
||||
private val maxStoredEntries = 300
|
||||
|
||||
/**
|
||||
* Get all watch progress items, sorted by last watched (most recent first)
|
||||
* For series, only returns the series-level entry (not individual episode entries)
|
||||
|
|
@ -42,22 +49,23 @@ class WatchProgressPreferences @Inject constructor(
|
|||
val json = preferences[watchProgressKey] ?: "{}"
|
||||
val allItems = parseProgressMap(json)
|
||||
|
||||
// Group all entries by contentId and pick the most recently watched.
|
||||
// When lastWatched is equal (e.g. batch mark-as-watched), prefer the highest season/episode.
|
||||
val latestByContent = allItems.values
|
||||
.groupBy { it.contentId }
|
||||
.mapValues { (_, items) ->
|
||||
items.maxWithOrNull(
|
||||
compareBy<WatchProgress> { it.lastWatched }
|
||||
.thenBy { it.season ?: 0 }
|
||||
.thenBy { it.episode ?: 0 }
|
||||
)
|
||||
}
|
||||
.values
|
||||
.filterNotNull()
|
||||
val contentLevelEntries = allItems.entries
|
||||
.filter { (key, progress) -> key == progress.contentId }
|
||||
.associate { it.value.contentId to it.value }
|
||||
.toMutableMap()
|
||||
|
||||
val result = latestByContent.sortedByDescending { it.lastWatched }
|
||||
result
|
||||
val latestEpisodeFallbacks = allItems.values
|
||||
.groupBy { it.contentId }
|
||||
.mapValues { (_, items) -> items.maxByOrNull { it.lastWatched } }
|
||||
|
||||
latestEpisodeFallbacks.forEach { (contentId, latest) ->
|
||||
if (contentLevelEntries[contentId] == null && latest != null) {
|
||||
contentLevelEntries[contentId] = latest
|
||||
}
|
||||
}
|
||||
|
||||
contentLevelEntries.values
|
||||
.sortedByDescending { it.lastWatched }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,10 +92,7 @@ class WatchProgressPreferences @Inject constructor(
|
|||
return store().data.map { preferences ->
|
||||
val json = preferences[watchProgressKey] ?: "{}"
|
||||
val map = parseProgressMap(json)
|
||||
// Try direct key first (movies), then find latest episode entry (series).
|
||||
map[contentId] ?: map.values
|
||||
.filter { it.contentId == contentId }
|
||||
.maxByOrNull { it.lastWatched }
|
||||
map[contentId]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,19 +129,19 @@ class WatchProgressPreferences @Inject constructor(
|
|||
store().edit { preferences ->
|
||||
val json = preferences[watchProgressKey] ?: "{}"
|
||||
val map = parseProgressMap(json).toMutableMap()
|
||||
upsertProgressEntries(map, listOf(progress))
|
||||
|
||||
val key = createKey(progress)
|
||||
map[key] = progress
|
||||
|
||||
val pruned = pruneOldItems(map)
|
||||
preferences[watchProgressKey] = gson.toJson(pruned)
|
||||
}
|
||||
}
|
||||
if (progress.season != null && progress.episode != null) {
|
||||
val seriesKey = progress.contentId
|
||||
val existingSeriesProgress = map[seriesKey]
|
||||
|
||||
if (existingSeriesProgress == null || progress.lastWatched > existingSeriesProgress.lastWatched) {
|
||||
map[seriesKey] = progress.copy(videoId = progress.videoId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveProgressBatch(progressList: List<WatchProgress>) {
|
||||
if (progressList.isEmpty()) return
|
||||
store().edit { preferences ->
|
||||
val json = preferences[watchProgressKey] ?: "{}"
|
||||
val map = parseProgressMap(json).toMutableMap()
|
||||
upsertProgressEntries(map, progressList)
|
||||
val pruned = pruneOldItems(map)
|
||||
preferences[watchProgressKey] = gson.toJson(pruned)
|
||||
}
|
||||
|
|
@ -177,69 +182,17 @@ class WatchProgressPreferences @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove watch progress for multiple episodes in a single DataStore transaction.
|
||||
*/
|
||||
suspend fun removeProgressBatch(contentId: String, episodes: List<Pair<Int, Int>>) {
|
||||
if (episodes.isEmpty()) return
|
||||
store().edit { preferences ->
|
||||
val json = preferences[watchProgressKey] ?: "{}"
|
||||
val map = parseProgressMap(json).toMutableMap()
|
||||
for ((season, episode) in episodes) {
|
||||
map.remove("${contentId}_s${season}e${episode}")
|
||||
}
|
||||
map.remove(contentId)
|
||||
Log.d(TAG, "removeProgressBatch contentId=$contentId removed=${episodes.size} episodes entriesAfter=${map.size}")
|
||||
preferences[watchProgressKey] = gson.toJson(map)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark content as completed
|
||||
*/
|
||||
suspend fun markAsCompleted(progress: WatchProgress) {
|
||||
// If the incoming duration is a dummy sentinel (≤ 1ms), check for an
|
||||
// existing local entry with a real duration from prior playback.
|
||||
// This creates a proper completed entry that syncs correctly cross-device.
|
||||
val effectiveDuration = if (progress.duration <= 1L) {
|
||||
val key = createKey(progress)
|
||||
val existing = getAllRawEntries()[key]
|
||||
existing?.duration?.takeIf { it > 1L } ?: progress.duration
|
||||
} else {
|
||||
progress.duration
|
||||
}
|
||||
|
||||
val completedProgress = progress.copy(
|
||||
position = effectiveDuration,
|
||||
duration = effectiveDuration,
|
||||
position = progress.duration,
|
||||
lastWatched = System.currentTimeMillis()
|
||||
)
|
||||
saveProgress(completedProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark multiple items as completed in a single DataStore transaction.
|
||||
*/
|
||||
suspend fun markAsCompletedBatch(progressList: List<WatchProgress>) {
|
||||
if (progressList.isEmpty()) return
|
||||
val rawEntries = getAllRawEntries()
|
||||
val now = System.currentTimeMillis()
|
||||
val completed = progressList.map { progress ->
|
||||
val effectiveDuration = if (progress.duration <= 1L) {
|
||||
val key = createKey(progress)
|
||||
rawEntries[key]?.duration?.takeIf { it > 1L } ?: progress.duration
|
||||
} else {
|
||||
progress.duration
|
||||
}
|
||||
progress.copy(
|
||||
position = effectiveDuration,
|
||||
duration = effectiveDuration,
|
||||
lastWatched = now
|
||||
)
|
||||
}
|
||||
saveProgressBatch(completed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw key→WatchProgress map from DataStore (for sync push).
|
||||
*/
|
||||
|
|
@ -271,7 +224,7 @@ class WatchProgressPreferences @Inject constructor(
|
|||
for ((key, remote) in remoteEntries) {
|
||||
val existing = local[key]
|
||||
if (existing == null || remote.lastWatched > existing.lastWatched) {
|
||||
local[key] = mergeDisplayMetadata(remote, existing)
|
||||
local[key] = remote
|
||||
Log.d("WatchProgressPrefs", " merged key=$key (existing=${existing != null})")
|
||||
} else {
|
||||
Log.d("WatchProgressPrefs", " skipped key=$key (local is newer)")
|
||||
|
|
@ -293,10 +246,7 @@ class WatchProgressPreferences @Inject constructor(
|
|||
Log.w(TAG, "replaceWithRemoteEntries: remote empty while local has ${current.size} entries; preserving local watch progress")
|
||||
return@edit
|
||||
}
|
||||
val merged = remoteEntries.mapValues { (key, remote) ->
|
||||
mergeDisplayMetadata(remote, current[key])
|
||||
}.toMutableMap()
|
||||
val pruned = pruneOldItems(merged)
|
||||
val pruned = pruneOldItems(remoteEntries.toMutableMap())
|
||||
Log.d("WatchProgressPrefs", "replaceWithRemoteEntries: ${pruned.size} entries after prune, writing to DataStore")
|
||||
preferences[watchProgressKey] = gson.toJson(pruned)
|
||||
}
|
||||
|
|
@ -319,37 +269,6 @@ class WatchProgressPreferences @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun upsertProgressEntries(
|
||||
map: MutableMap<String, WatchProgress>,
|
||||
progressList: List<WatchProgress>
|
||||
) {
|
||||
progressList.forEach { progress ->
|
||||
val key = createKey(progress)
|
||||
map[key] = progress
|
||||
|
||||
// Remove legacy series-level mirror key if this is an episode entry.
|
||||
// Mirror keys caused race conditions with stale progress data.
|
||||
if (progress.season != null && progress.episode != null) {
|
||||
val seriesKey = progress.contentId
|
||||
if (seriesKey != key && map.containsKey(seriesKey)) {
|
||||
map.remove(seriesKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeDisplayMetadata(remote: WatchProgress, existing: WatchProgress?): WatchProgress {
|
||||
if (existing == null) return remote
|
||||
return remote.copy(
|
||||
name = existing.name.takeIf { it.isNotBlank() } ?: remote.name.takeIf { it.isNotBlank() } ?: existing.name,
|
||||
poster = existing.poster ?: remote.poster,
|
||||
backdrop = existing.backdrop ?: remote.backdrop,
|
||||
logo = existing.logo ?: remote.logo,
|
||||
episodeTitle = existing.episodeTitle ?: remote.episodeTitle,
|
||||
addonBaseUrl = remote.addonBaseUrl ?: existing.addonBaseUrl
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseProgressMap(json: String): Map<String, WatchProgress> {
|
||||
return try {
|
||||
// Parse entry-by-entry so one malformed value doesn't wipe the entire map.
|
||||
|
|
@ -455,6 +374,75 @@ class WatchProgressPreferences @Inject constructor(
|
|||
}
|
||||
|
||||
private fun pruneOldItems(map: MutableMap<String, WatchProgress>): Map<String, WatchProgress> {
|
||||
return map
|
||||
if (map.isEmpty()) return map
|
||||
|
||||
val latestByContent = map.values
|
||||
.groupBy { it.contentId }
|
||||
.mapValues { (_, items) -> items.maxOf { it.lastWatched } }
|
||||
|
||||
val inProgressContentIds = map.values
|
||||
.asSequence()
|
||||
.filter { it.isInProgress() }
|
||||
.map { it.contentId }
|
||||
.toSet()
|
||||
|
||||
val sortedContentIds = latestByContent
|
||||
.entries
|
||||
.sortedByDescending { it.value }
|
||||
.map { it.key }
|
||||
|
||||
val keepContentIds = buildList {
|
||||
sortedContentIds
|
||||
.filter { it in inProgressContentIds }
|
||||
.forEach { add(it) }
|
||||
sortedContentIds
|
||||
.filter { it !in inProgressContentIds }
|
||||
.forEach { add(it) }
|
||||
}
|
||||
.distinct()
|
||||
.take(maxItems)
|
||||
|
||||
val keepContentIdSet = keepContentIds.toSet()
|
||||
|
||||
val filteredByContent = map.filterValues { it.contentId in keepContentIdSet }
|
||||
val boundedByContent = mutableMapOf<String, WatchProgress>()
|
||||
|
||||
keepContentIds.forEach { contentId ->
|
||||
val entriesForContent = filteredByContent.filterValues { it.contentId == contentId }
|
||||
|
||||
// Keep canonical content-level record when present.
|
||||
entriesForContent[contentId]?.let { boundedByContent[contentId] = it }
|
||||
|
||||
val recentEpisodeEntries = entriesForContent
|
||||
.filterKeys { it != contentId }
|
||||
.entries
|
||||
.sortedByDescending { it.value.lastWatched }
|
||||
.take(maxEpisodesPerContent)
|
||||
|
||||
recentEpisodeEntries.forEach { (key, value) ->
|
||||
boundedByContent[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (boundedByContent.size <= maxStoredEntries) return boundedByContent
|
||||
|
||||
val pinnedContentKeys = keepContentIds.filter { boundedByContent.containsKey(it) }.toSet()
|
||||
val remainingSlots = (maxStoredEntries - pinnedContentKeys.size).coerceAtLeast(0)
|
||||
|
||||
val limited = mutableMapOf<String, WatchProgress>()
|
||||
pinnedContentKeys.forEach { key ->
|
||||
boundedByContent[key]?.let { limited[key] = it }
|
||||
}
|
||||
|
||||
boundedByContent.entries
|
||||
.asSequence()
|
||||
.filter { (key, _) -> key !in pinnedContentKeys }
|
||||
.sortedByDescending { (_, value) -> value.lastWatched }
|
||||
.take(remainingSlots)
|
||||
.forEach { (key, value) ->
|
||||
limited[key] = value
|
||||
}
|
||||
|
||||
return limited
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class WatchedItemsPreferences @Inject constructor(
|
|||
private val gson = Gson()
|
||||
private val watchedItemsKey = stringSetPreferencesKey("watched_items")
|
||||
|
||||
internal val allItems: Flow<List<WatchedItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
private val allItems: Flow<List<WatchedItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
|
||||
factory.get(pid, FEATURE).data.map { preferences ->
|
||||
val raw = preferences[watchedItemsKey] ?: emptySet()
|
||||
raw.mapNotNull { json ->
|
||||
|
|
@ -56,13 +56,6 @@ class WatchedItemsPreferences @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun getWatchedEpisodesWithTimestamps(contentId: String): Flow<Map<Pair<Int, Int>, Long>> {
|
||||
return allItems.map { items ->
|
||||
items.filter { it.contentId == contentId && it.season != null && it.episode != null }
|
||||
.associate { (it.season!! to it.episode!!) to it.watchedAt }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markAsWatched(item: WatchedItem) {
|
||||
store().edit { preferences ->
|
||||
val current = preferences[watchedItemsKey] ?: emptySet()
|
||||
|
|
@ -79,22 +72,6 @@ class WatchedItemsPreferences @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun markAsWatchedBatch(items: List<WatchedItem>) {
|
||||
if (items.isEmpty()) return
|
||||
store().edit { preferences ->
|
||||
val current = preferences[watchedItemsKey] ?: emptySet()
|
||||
val newKeys = items.map { Triple(it.contentId, it.season, it.episode) }.toSet()
|
||||
val filtered = current.filterNot { json ->
|
||||
runCatching {
|
||||
gson.fromJson(json, WatchedItem::class.java)
|
||||
}.getOrNull()?.let { existing ->
|
||||
Triple(existing.contentId, existing.season, existing.episode) in newKeys
|
||||
} ?: false
|
||||
}
|
||||
preferences[watchedItemsKey] = filtered.toSet() + items.map { gson.toJson(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unmarkAsWatched(contentId: String, season: Int? = null, episode: Int? = null) {
|
||||
store().edit { preferences ->
|
||||
val current = preferences[watchedItemsKey] ?: emptySet()
|
||||
|
|
@ -111,22 +88,6 @@ class WatchedItemsPreferences @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun unmarkAsWatchedBatch(contentId: String, episodes: List<Pair<Int, Int>>) {
|
||||
if (episodes.isEmpty()) return
|
||||
val removeKeys = episodes.map { (s, e) -> Triple(contentId, s, e) }.toSet()
|
||||
store().edit { preferences ->
|
||||
val current = preferences[watchedItemsKey] ?: emptySet()
|
||||
val filtered = current.filterNot { json ->
|
||||
runCatching {
|
||||
gson.fromJson(json, WatchedItem::class.java)
|
||||
}.getOrNull()?.let { existing ->
|
||||
Triple(existing.contentId, existing.season, existing.episode) in removeKeys
|
||||
} ?: false
|
||||
}
|
||||
preferences[watchedItemsKey] = filtered.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAllItems(): List<WatchedItem> {
|
||||
return allItems.first()
|
||||
}
|
||||
|
|
@ -165,10 +126,4 @@ class WatchedItemsPreferences @Inject constructor(
|
|||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
store().edit { preferences ->
|
||||
preferences.remove(watchedItemsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
package com.nuvio.tv.data.local
|
||||
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Shared holder for fully-watched series IDs derived from the CW pipeline.
|
||||
* Updated by HomeViewModel, observed by any screen that needs series watched badges.
|
||||
* Persisted per profile so badges appear instantly on cold start.
|
||||
*/
|
||||
@Singleton
|
||||
class WatchedSeriesStateHolder @Inject constructor(
|
||||
private val factory: ProfileDataStoreFactory,
|
||||
private val profileManager: ProfileManager
|
||||
) {
|
||||
companion object {
|
||||
private const val FEATURE = "watched_series_cache"
|
||||
private val KEY = stringSetPreferencesKey("fully_watched_ids")
|
||||
private val TIMESTAMPS_KEY = stringPreferencesKey("validated_timestamps")
|
||||
private const val VALIDATION_TTL_MS = 12L * 60 * 60 * 1000 // 12 hours
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val gson = Gson()
|
||||
private val _fullyWatchedSeriesIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val fullyWatchedSeriesIds: StateFlow<Set<String>> = _fullyWatchedSeriesIds.asStateFlow()
|
||||
|
||||
/** Per-series validation timestamps (contentId → epochMs). */
|
||||
@Volatile
|
||||
private var validatedAtMap: Map<String, Long> = emptyMap()
|
||||
private var loaded = false
|
||||
|
||||
private fun store() = factory.get(profileManager.activeProfileId.value, FEATURE)
|
||||
|
||||
fun loadFromDisk() {
|
||||
if (loaded) return
|
||||
scope.launch {
|
||||
val prefs = store().data.first()
|
||||
val persisted = prefs[KEY] ?: emptySet()
|
||||
validatedAtMap = parseTimestamps(prefs[TIMESTAMPS_KEY])
|
||||
if (_fullyWatchedSeriesIds.value.isEmpty() && persisted.isNotEmpty()) {
|
||||
_fullyWatchedSeriesIds.value = persisted
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
fun update(ids: Set<String>) {
|
||||
_fullyWatchedSeriesIds.value = ids
|
||||
scope.launch {
|
||||
store().edit { prefs -> prefs[KEY] = ids }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update badge IDs and mark the given series as freshly validated.
|
||||
*/
|
||||
fun updateWithValidation(ids: Set<String>, validatedIds: Set<String>) {
|
||||
_fullyWatchedSeriesIds.value = ids
|
||||
val now = System.currentTimeMillis()
|
||||
val updated = validatedAtMap.toMutableMap()
|
||||
validatedIds.forEach { updated[it] = now }
|
||||
// Prune entries no longer in the badge set.
|
||||
updated.keys.retainAll(ids)
|
||||
validatedAtMap = updated
|
||||
scope.launch {
|
||||
store().edit { prefs ->
|
||||
prefs[KEY] = ids
|
||||
prefs[TIMESTAMPS_KEY] = gson.toJson(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given series was validated within the TTL window.
|
||||
*/
|
||||
fun isSeriesValidationFresh(contentId: String): Boolean {
|
||||
val ts = validatedAtMap[contentId] ?: return false
|
||||
return System.currentTimeMillis() - ts < VALIDATION_TTL_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the given set to only those series that need re-validation
|
||||
* (not validated within TTL).
|
||||
*/
|
||||
fun filterStaleIds(ids: Set<String>): Set<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
return ids.filter { id ->
|
||||
val ts = validatedAtMap[id] ?: return@filter true
|
||||
now - ts >= VALIDATION_TTL_MS
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
private fun parseTimestamps(json: String?): Map<String, Long> {
|
||||
if (json.isNullOrBlank()) return emptyMap()
|
||||
return runCatching {
|
||||
val type = object : TypeToken<Map<String, Long>>() {}.type
|
||||
gson.fromJson<Map<String, Long>>(json, type) ?: emptyMap()
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue