Merge remote-tracking branch 'origin/dev' into feature/player-quality-badges

# Conflicts:
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
#	app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt
This commit is contained in:
HALİL İBRAHİM ÖZDEMİR 2026-03-31 22:21:29 +03:00
commit 32aa5cf46a
300 changed files with 92083 additions and 7662 deletions

220
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,220 @@
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 Normal file
View file

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

View file

@ -0,0 +1,78 @@
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 Normal file
View file

@ -0,0 +1,42 @@
## 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 Normal file
View file

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

View file

@ -0,0 +1,83 @@
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 Normal file
View file

@ -0,0 +1,75 @@
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 Normal file
View file

@ -0,0 +1,75 @@
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 Normal file
View file

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

8
.gitignore vendored
View file

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

81
CONTRIBUTING.md Normal file
View file

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

View file

@ -6,7 +6,6 @@
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
@ -25,16 +24,28 @@ 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 = 26
minSdk = 24
targetSdk = 36
versionCode = 24
versionName = "0.4.6-beta"
versionCode = 49
versionName = "0.5.6-beta"
buildConfigField("String", "PARENTAL_GUIDE_API_URL", "\"${localProperties.getProperty("PARENTAL_GUIDE_API_URL", "")}\"")
buildConfigField("String", "INTRODB_API_URL", "\"${localProperties.getProperty("INTRODB_API_URL", "")}\"")
@ -45,7 +56,11 @@ android {
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\"")
@ -54,10 +69,10 @@ android {
signingConfigs {
create("release") {
keyAlias = "nuviotv"
keyPassword = "815787"
storeFile = file("../nuviotv.jks")
storePassword = "815787"
keyAlias = releaseKeyAliasValue
keyPassword = releaseKeyPasswordValue
storeFile = releaseStoreFilePath?.let(::file) ?: file("../nuviotv.jks")
storePassword = releaseStorePasswordValue
}
}
@ -78,14 +93,22 @@ 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 = signingConfigs.getByName("release")
signingConfig = if (useDebugReleaseSigning) {
signingConfigs.getByName("debug")
} else {
signingConfigs.getByName("release")
}
buildConfigField("boolean", "IS_DEBUG_BUILD", "false")
@ -98,6 +121,22 @@ 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"
}
}
@ -113,6 +152,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
@ -164,10 +204,21 @@ 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(":benchmark")) // TODO: create benchmark module later
baselineProfile(project(":baselineprofile"))
implementation(libs.androidx.core.ktx)
implementation("androidx.core:core-splashscreen:1.0.1")
implementation(libs.androidx.appcompat)
@ -272,8 +323,8 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
// Performance profiling
implementation("androidx.metrics:metrics-performance:1.0.0-beta01") // JankStats
implementation("androidx.compose.runtime:runtime-tracing") // Compose function names in Perfetto
implementation("androidx.metrics:metrics-performance:1.0.0-rc01") // JankStats
debugImplementation("androidx.compose.runtime:runtime-tracing")
// 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.

View file

@ -25,12 +25,6 @@
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"

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ 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
@ -27,6 +28,7 @@ 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
@ -54,6 +56,7 @@ 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
@ -82,7 +85,9 @@ 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
@ -96,6 +101,7 @@ 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
@ -104,10 +110,13 @@ 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
@ -132,6 +141,9 @@ 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,
@ -141,6 +153,7 @@ 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,
@ -162,6 +175,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var startupSyncService: StartupSyncService
@Inject
lateinit var profileSettingsSyncService: ProfileSettingsSyncService
@Inject
lateinit var profileSyncService: ProfileSyncService
@ -174,6 +190,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var appOnboardingDataStore: AppOnboardingDataStore
@Inject
lateinit var avatarRepository: AvatarRepository
private lateinit var jankStats: JankStats
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@ -187,6 +206,9 @@ 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)
}
}
@ -195,8 +217,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
window?.setBackgroundDrawable(null)
setContent {
var hasSelectedProfileThisSession by remember { mutableStateOf(false) }
var hasSelectedProfileThisSession by rememberSaveable { mutableStateOf(false) }
var onboardingCompletedThisSession by remember { mutableStateOf(false) }
var onboardingProfileSyncInProgress by remember { mutableStateOf(false) }
val hasSeenAuthQrOnFirstLaunch by appOnboardingDataStore
@ -217,33 +240,63 @@ 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,
layoutPreferenceDataStore.modernSidebarBlurEnabled
) { theme, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled, modernSidebarBlurPref ->
) { theme, font, hasChosenLayout, sidebarCollapsed, modernSidebarEnabled ->
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) {
NuvioTheme(appTheme = mainUiPrefs.theme, appFont = mainUiPrefs.font) {
CompositionLocalProvider(
LocalBringIntoViewSpec provides NuvioScrollDefaults.smoothScrollSpec
) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RectangleShape
shape = RectangleShape,
colors = SurfaceDefaults.colors(
containerColor = NuvioColors.Background
)
) {
if (hasSeenAuthQrOnFirstLaunch == null) {
Box(
@ -301,7 +354,7 @@ class MainActivity : ComponentActivity() {
}
val shouldShowProfileSelection =
!hasSelectedProfileThisSession && profiles.size > 1
!hasSelectedProfileThisSession && (profiles.size > 1 || activeProfileHasPin)
if (shouldShowProfileSelection) {
ProfileSelectionScreen(
@ -415,6 +468,7 @@ class MainActivity : ComponentActivity() {
hideBuiltInHeaders = hideBuiltInHeadersForFloatingPill,
activeProfileName = activeProfile?.name ?: "",
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = profiles.size > 1,
onSwitchProfile = { hasSelectedProfileThisSession = false },
onExitApp = {
@ -434,6 +488,7 @@ class MainActivity : ComponentActivity() {
hideBuiltInHeaders = false,
activeProfileName = activeProfile?.name ?: "",
activeProfileColorHex = activeProfile?.avatarColorHex ?: "#1E88E5",
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = profiles.size > 1,
onSwitchProfile = { hasSelectedProfileThisSession = false },
onExitApp = {
@ -469,6 +524,10 @@ 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() {
@ -478,10 +537,7 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
startupSyncService.requestSyncNow()
lifecycleScope.launch {
traktProgressService.refreshNow()
}
profileSettingsSyncService.requestForegroundPull()
}
}
@ -498,6 +554,7 @@ private fun LegacySidebarScaffold(
hideBuiltInHeaders: Boolean,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit,
onExitApp: () -> Unit
@ -513,9 +570,11 @@ private fun LegacySidebarScaffold(
}
val closedDrawerWidth = if (sidebarCollapsed) 0.dp else 72.dp
val openDrawerWidth = 216.dp
val openDrawerWidth = 196.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) }
@ -533,7 +592,7 @@ private fun LegacySidebarScaffold(
return@LaunchedEffect
}
repeat(2) { withFrameNanos { } }
focusManager.moveFocus(FocusDirection.Right)
runCatching { contentFocusRequester.requestFocus() }
pendingContentFocusTransfer = false
}
@ -567,7 +626,8 @@ private fun LegacySidebarScaffold(
.padding(12.dp)
.selectableGroup()
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.DirectionRight && keyEvent.type == KeyEventType.KeyDown) {
val closeKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
if (keyEvent.key == closeKey && keyEvent.type == KeyEventType.KeyDown) {
drawerState.setValue(DrawerValue.Closed)
pendingContentFocusTransfer = false
true
@ -577,21 +637,69 @@ 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))
Image(
painter = painterResource(id = R.drawable.app_logo_wordmark),
contentDescription = "NuvioTV",
modifier = Modifier
.fillMaxWidth()
.height(42.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)
)
}
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),
@ -621,64 +729,28 @@ private fun LegacySidebarScaffold(
}
Spacer(modifier = Modifier.weight(1f))
if (isExpanded && showProfileSelector && 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 = if (showSidebar) closedDrawerWidth else 0.dp
val contentStartPadding by animateDpAsState(
targetValue = if (showSidebar) closedDrawerWidth else 0.dp,
animationSpec = tween(350),
label = "contentStartPadding"
)
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 == Key.DirectionLeft
keyEvent.key == openKey
) {
if (focusManager.moveFocus(FocusDirection.Left)) {
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
true
} else {
pendingSidebarFocusRequest = true
@ -690,11 +762,16 @@ private fun LegacySidebarScaffold(
}
}
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
CompositionLocalProvider(
LocalSidebarExpanded provides (drawerState.currentValue == DrawerValue.Open),
LocalContentFocusRequester provides contentFocusRequester
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
}
}
}
@ -714,7 +791,7 @@ private fun LegacySidebarButton(
val backgroundColor by animateColorAsState(
targetValue = when {
isFocused -> NuvioColors.FocusBackground
selected -> NuvioColors.BackgroundCard
expanded && selected -> NuvioColors.Secondary
else -> Color.Transparent
},
label = "legacySidebarItemBackground"
@ -722,17 +799,26 @@ private fun LegacySidebarButton(
val contentColor by animateColorAsState(
targetValue = when {
isFocused -> NuvioColors.TextPrimary
selected -> NuvioColors.TextPrimary
expanded && selected -> NuvioColors.OnSecondary
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),
@ -740,7 +826,7 @@ private fun LegacySidebarButton(
DrawerItemIcon(
iconRes = iconRes,
icon = icon,
tint = contentColor,
tint = iconTint,
modifier = if (expanded) {
Modifier
.size(22.dp)
@ -782,6 +868,7 @@ private fun ModernSidebarScaffold(
hideBuiltInHeaders: Boolean,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit,
onExitApp: () -> Unit
@ -791,6 +878,8 @@ 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() }
}
@ -804,6 +893,8 @@ 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) {
@ -958,7 +1049,7 @@ private fun ModernSidebarScaffold(
return@LaunchedEffect
}
repeat(2) { withFrameNanos { } }
focusManager.moveFocus(FocusDirection.Right)
runCatching { contentFocusRequester.requestFocus() }
pendingContentFocusTransfer = false
}
@ -983,13 +1074,6 @@ private fun ModernSidebarScaffold(
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (shouldApplySidebarHaze) {
Modifier.haze(sidebarHazeState)
} else {
Modifier
}
)
.onPreviewKeyEvent { keyEvent ->
if (
isSidebarExpanded &&
@ -1012,8 +1096,9 @@ private fun ModernSidebarScaffold(
else -> Unit
}
}
if (keyEvent.key == Key.DirectionLeft) {
if (focusManager.moveFocus(FocusDirection.Left)) {
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
if (keyEvent.key == openKey) {
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
true
} else {
isSidebarExpanded = true
@ -1029,11 +1114,16 @@ private fun ModernSidebarScaffold(
}
}
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
CompositionLocalProvider(
LocalSidebarExpanded provides isSidebarExpanded,
LocalContentFocusRequester provides contentFocusRequester
) {
NuvioNavHost(
navController = navController,
startDestination = startDestination,
hideBuiltInHeaders = hideBuiltInHeaders
)
}
}
if (showSidebar && (sidebarVisible || sidebarWidth > 0.dp)) {
@ -1064,17 +1154,22 @@ private fun ModernSidebarScaffold(
}
when (keyEvent.key) {
Key.DirectionUp -> {
focusedDrawerIndex == 0
focusedDrawerIndex == sidebarTopBoundaryIndex
}
Key.DirectionDown -> {
focusedDrawerIndex > drawerItems.lastIndex
focusedDrawerIndex == drawerItems.lastIndex
}
Key.DirectionRight -> {
pendingContentFocusTransfer = false
sidebarCollapsePending = true
true
Key.DirectionRight, Key.DirectionLeft -> {
val collapseKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
if (keyEvent.key == collapseKey) {
pendingContentFocusTransfer = false
sidebarCollapsePending = true
true
} else {
false
}
}
else -> false
@ -1109,6 +1204,7 @@ private fun ModernSidebarScaffold(
},
activeProfileName = activeProfileName,
activeProfileColorHex = activeProfileColorHex,
activeProfileAvatarImageUrl = activeProfileAvatarImageUrl,
showProfileSelector = showProfileSelector,
onSwitchProfile = onSwitchProfile
)
@ -1130,7 +1226,7 @@ private fun ModernSidebarScaffold(
.align(Alignment.TopStart)
.offset {
IntOffset(
(40.dp + sidebarSlideX + sidebarDeflateOffsetX).roundToPx(),
14.dp.roundToPx(),
(16.dp + sidebarDeflateOffsetY).roundToPx()
)
}
@ -1204,7 +1300,6 @@ private fun CollapsedSidebarPill(
.graphicsLayer {
shape = pillShape
clip = true
compositingStrategy = CompositingStrategy.Offscreen
}
.clip(pillShape)
.background(brush = pillBackgroundBrush, shape = pillShape)

View file

@ -41,10 +41,10 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
@ -57,6 +57,10 @@ 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>,
@ -75,6 +79,7 @@ internal fun ModernSidebarBlurPanel(
onDrawerItemClick: (String) -> Unit,
activeProfileName: String,
activeProfileColorHex: String,
activeProfileAvatarImageUrl: String?,
showProfileSelector: Boolean,
onSwitchProfile: () -> Unit
) {
@ -130,19 +135,43 @@ internal fun ModernSidebarBlurPanel(
.border(width = 1.dp, color = panelBorderColor, shape = panelShape)
.padding(horizontal = 12.dp, vertical = 14.dp)
) {
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
)
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
)
}
}
Spacer(modifier = Modifier.height(16.dp))
@ -180,25 +209,6 @@ internal fun ModernSidebarBlurPanel(
}
}
if (showProfileSelector && 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)
)
}
}
}
}
@ -234,9 +244,6 @@ 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)
@ -260,7 +267,7 @@ private fun SidebarNavigationItem(
) {
Box(
modifier = Modifier
.size(iconContainerSize)
.size(SidebarLeadingVisualSize)
.clip(CircleShape)
.background(iconCircleColor)
.padding(6.dp)
@ -286,7 +293,7 @@ private fun SidebarNavigationItem(
)
}
}
Spacer(modifier = Modifier.width(contentGap))
Spacer(modifier = Modifier.width(SidebarContentGap))
Text(
text = label,
@ -298,7 +305,6 @@ private fun SidebarNavigationItem(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(iconContainerSize + contentGap))
}
}
@ -306,6 +312,7 @@ private fun SidebarNavigationItem(
private fun SidebarProfileItem(
profileName: String,
profileColorHex: String,
profileAvatarImageUrl: String?,
focusEnabled: Boolean,
labelAlpha: Float,
onFocusChanged: (Boolean) -> Unit,
@ -324,9 +331,6 @@ private fun SidebarProfileItem(
animationSpec = tween(durationMillis = 180),
label = "profileItemBorder"
)
val contentGap = 14.dp
val iconContainerSize = 34.dp
Row(
modifier = modifier
.clip(shape)
@ -348,19 +352,29 @@ private fun SidebarProfileItem(
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
ProfileAvatarCircle(
name = profileName,
colorHex = profileColorHex,
size = iconContainerSize
)
Spacer(modifier = Modifier.width(contentGap))
Box(
modifier = Modifier.size(SidebarLeadingVisualSize),
contentAlignment = Alignment.Center
) {
ProfileAvatarCircle(
name = profileName,
colorHex = profileColorHex,
size = SidebarLeadingVisualSize,
avatarImageUrl = profileAvatarImageUrl
)
}
Spacer(modifier = Modifier.width(SidebarProfileContentGap))
Text(
text = profileName,
color = Color.White,
modifier = Modifier
.weight(1f)
.graphicsLayer { alpha = labelAlpha },
style = androidx.tv.material3.MaterialTheme.typography.titleLarge,
maxLines = 1
style = androidx.tv.material3.MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -2,6 +2,7 @@ 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
@ -34,7 +35,8 @@ private const val TAG = "AuthManager"
class AuthManager @Inject constructor(
private val auth: Auth,
private val postgrest: Postgrest,
private val httpClient: OkHttpClient
private val httpClient: OkHttpClient,
private val authSessionNoticeDataStore: AuthSessionNoticeDataStore
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true }
@ -62,8 +64,10 @@ 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()
}
}
}
@ -78,12 +82,14 @@ 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 -> {
@ -200,7 +206,12 @@ class AuthManager @Inject constructor(
}
}
suspend fun signOut() {
suspend fun signOut(explicit: Boolean = true) {
if (explicit) {
authSessionNoticeDataStore.markNuvioExplicitLogout()
} else {
authSessionNoticeDataStore.markUnexpectedNuvioLogoutIfNeeded()
}
try {
auth.signOut()
} catch (e: Exception) {

View file

@ -5,7 +5,9 @@ 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
@ -27,11 +29,17 @@ 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)
@ -50,15 +58,28 @@ object NetworkModule {
@Provides
@Singleton
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()
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)
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
else HttpLoggingInterceptor.Level.NONE
})
.build()
}
@Provides
@Singleton
@ -230,6 +251,21 @@ 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
@ -247,6 +283,26 @@ object NetworkModule {
fun provideGitHubReleaseApi(@Named("github") retrofit: Retrofit): GitHubReleaseApi =
retrofit.create(GitHubReleaseApi::class.java)
@Provides
@Singleton
@Named("donations")
fun provideDonationsRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
val baseUrl = BuildConfig.DONATIONS_BASE_URL
.takeIf { it.isNotBlank() }
?: error("DONATIONS_BASE_URL is missing. Set it in local.properties or local.dev.properties.")
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideDonationsApi(@Named("donations") retrofit: Retrofit): DonationsApi =
retrofit.create(DonationsApi::class.java)
// --- Trailer API ---
@Provides

View file

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

View file

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

View file

@ -31,6 +31,8 @@ object FrameRateUtils {
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"
@ -45,7 +47,9 @@ object FrameRateUtils {
data class FrameRateDetection(
val raw: Float,
val snapped: Float
val snapped: Float,
val videoWidth: Int? = null,
val videoHeight: Int? = null
)
private fun matchesTargetRefresh(refreshRate: Float, target: Float): Boolean {
@ -136,9 +140,38 @@ object FrameRateUtils {
return modeExact ?: modeDouble ?: modePulldown ?: modeFallback ?: activeMode
}
private fun hasValidVideoSize(videoWidth: Int?, videoHeight: Int?): Boolean {
return (videoWidth ?: 0) > 0 && (videoHeight ?: 0) > 0
}
private fun normalizedSize(width: Int, height: Int): Pair<Int, Int> {
return if (width >= height) width to height else height to width
}
private fun resolutionDistanceSquared(mode: Display.Mode, targetWidth: Int, targetHeight: Int): Long {
val (modeWidth, modeHeight) = normalizedSize(mode.physicalWidth, mode.physicalHeight)
val dw = modeWidth - targetWidth
val dh = modeHeight - targetHeight
return dw.toLong() * dw.toLong() + dh.toLong() * dh.toLong()
}
private fun selectModesForVideoResolution(
modes: List<Display.Mode>,
videoWidth: Int,
videoHeight: Int
): List<Display.Mode> {
if (modes.isEmpty()) return modes
val (targetWidth, targetHeight) = normalizedSize(videoWidth, videoHeight)
val minDistance = modes.minOfOrNull { resolutionDistanceSquared(it, targetWidth, targetHeight) } ?: return modes
return modes.filter { resolutionDistanceSquared(it, targetWidth, targetHeight) == minDistance }
}
suspend fun matchFrameRateAndWait(
activity: Activity,
frameRate: Float
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
@ -152,7 +185,22 @@ object FrameRateUtils {
it.physicalWidth == activeMode.physicalWidth &&
it.physicalHeight == activeMode.physicalHeight
}
if (sameSizeModes.size <= 1) {
val candidateModes = if (resolutionMatchingEnabled && hasValidVideoSize(videoWidth, videoHeight)) {
selectModesForVideoResolution(
modes = display.supportedModes.toList(),
videoWidth = videoWidth ?: activeMode.physicalWidth,
videoHeight = videoHeight ?: activeMode.physicalHeight
)
} 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)
@ -161,7 +209,7 @@ object FrameRateUtils {
val modeBest = chooseBestModeForFrameRate(
activeMode = activeMode,
modes = sameSizeModes,
modes = candidateModes,
frameRate = frameRate
)
recordOriginalMode(display)
@ -289,13 +337,32 @@ 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? {
detectFrameRateWithNextLib(context, sourceUrl, headers)?.let { return it }
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()) {
@ -336,11 +403,13 @@ object FrameRateUtils {
val video = mediaInfo.videoStream ?: return@forEach
val measured = video.frameRate.toFloat()
if (!measured.isFinite() || measured <= 0f) return@forEach
if (!isValidVideoFrameRate(measured)) return@forEach
return FrameRateDetection(
raw = measured,
snapped = snapToStandardRate(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}")
@ -365,19 +434,45 @@ 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 = 30
val ignoreSamples = 3
val targetSamples = 350 + ignoreSamples
while (timestamps.size < targetSamples) {
@ -395,17 +490,19 @@ object FrameRateUtils {
}
val sampleCount = (timestamps.size - ignoreSamples - 1).coerceAtLeast(1)
if (sampleCount < 90) return null
if (sampleCount < 30) return null
val averageFrameDurationUs = totalFrameDurationUs.toFloat() / sampleCount.toFloat()
if (averageFrameDurationUs <= 0f) return null
val measured = 1_000_000f / averageFrameDurationUs
if (measured < 10f || measured > 120f) return null
if (!isValidVideoFrameRate(measured)) return null
FrameRateDetection(
raw = measured,
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs)
snapped = snapProbeRateByFrameDuration(measured, averageFrameDurationUs),
videoWidth = detectedVideoWidth,
videoHeight = detectedVideoHeight
)
} catch (e: Exception) {
Log.w(TAG, "Frame rate probe failed: ${e.message}")

View file

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

View file

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

View file

@ -10,6 +10,7 @@ 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.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@ -51,6 +52,7 @@ 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()
@ -422,15 +424,17 @@ class PluginManager @Inject constructor(
val settings = dataStore.getScraperSettings(scraper.id)
Log.d(TAG, "Executing scraper: ${scraper.name}")
val results = runtime.executePlugin(
code = code,
tmdbId = tmdbId,
mediaType = mediaType,
season = season,
episode = episode,
scraperId = scraper.id,
scraperSettings = settings
)
val results = withContext(Dispatchers.IO + NonCancellable) {
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) }

View file

@ -3,10 +3,10 @@ package com.nuvio.tv.core.plugin
import android.util.Log
import com.dokar.quickjs.binding.define
import com.dokar.quickjs.binding.function
import com.dokar.quickjs.binding.asyncFunction
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,7 +39,10 @@ import javax.inject.Singleton
private const val TAG = "PluginRuntime"
private const val PLUGIN_TIMEOUT_MS = 60_000L
private const val MAX_FETCH_RESPONSE_BYTES = 2 * 1024 * 1024
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]"
@Singleton
class PluginRuntime @Inject constructor() {
@ -47,6 +50,7 @@ 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)
@ -172,7 +176,7 @@ class PluginRuntime @Inject constructor() {
}
}
asyncFunction("__native_fetch") { args ->
function("__native_fetch") { args ->
val url = args.getOrNull(0)?.toString() ?: ""
val method = args.getOrNull(1)?.toString() ?: "GET"
val headersJson = args.getOrNull(2)?.toString() ?: "{}"
@ -369,7 +373,7 @@ class PluginRuntime @Inject constructor() {
""".trimIndent()
evaluate<Any?>(callCode)
}
}
return parseJsonResults(resultJson)
@ -447,10 +451,10 @@ class PluginRuntime @Inject constructor() {
response.use { httpResponse ->
val bodyContentType = httpResponse.body?.contentType()
val contentEncoding = httpResponse.header("Content-Encoding")?.lowercase()?.trim()
val decodedBytes = try {
val decodedRead = try {
val stream = httpResponse.body?.byteStream()
if (stream == null) {
ByteArray(0)
BoundedReadResult(ByteArray(0), false)
} else {
val decodeStream: InputStream = when (contentEncoding) {
"gzip" -> GZIPInputStream(stream)
@ -463,18 +467,14 @@ class PluginRuntime @Inject constructor() {
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read/decode response body for $url: ${e.message}")
ByteArray(0)
BoundedReadResult(ByteArray(0), false)
}
val charset = bodyContentType?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
val responseBody = try {
String(decodedBytes, charset)
} catch (e: Exception) {
String(decodedBytes, Charsets.UTF_8)
}
val responseBody = decodeBodyToSafeString(decodedRead.bytes, charset)
val responseHeaders = mutableMapOf<String, String>()
httpResponse.headers.forEach { (name, value) ->
responseHeaders[name.lowercase()] = value
responseHeaders[name.lowercase()] = truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS)
}
val result = mapOf(
@ -483,7 +483,8 @@ class PluginRuntime @Inject constructor() {
"statusText" to httpResponse.message,
"url" to httpResponse.request.url.toString(),
"body" to responseBody,
"headers" to responseHeaders
"headers" to responseHeaders,
"truncated" to decodedRead.truncated
)
Log.d(TAG, "Fetch result: ${httpResponse.code} ${httpResponse.message} url=$url bodyLen=${responseBody.length} bodyPreview=${responseBody.take(300)}")
@ -505,10 +506,32 @@ class PluginRuntime @Inject constructor() {
}
}
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): ByteArray {
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 {
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))
@ -516,7 +539,10 @@ class PluginRuntime @Inject constructor() {
out.write(buffer, 0, read)
remaining -= read
}
return out.toByteArray()
if (remaining == 0) {
truncated = stream.read() != -1
}
return BoundedReadResult(out.toByteArray(), truncated)
}
private fun parseUrl(urlString: String): String {
@ -550,7 +576,7 @@ class PluginRuntime @Inject constructor() {
globalThis.SCRAPER_ID = "$scraperId";
globalThis.SCRAPER_SETTINGS = $settingsJson;
if (typeof TMDB_API_KEY === 'undefined') {
globalThis.TMDB_API_KEY = "1865f43a0549ca50d341dd9ab8b29f49";
globalThis.TMDB_API_KEY = "${BuildConfig.TMDB_API_KEY}";
}
if (typeof globalThis.global === 'undefined') {
globalThis.global = globalThis;
@ -581,7 +607,7 @@ class PluginRuntime @Inject constructor() {
headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
}
var result = await __native_fetch(url, method, JSON.stringify(headers), body);
var result = __native_fetch(url, method, JSON.stringify(headers), body);
var parsed = JSON.parse(result);
if (signal && signal.aborted) {
@ -604,10 +630,15 @@ 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) {
return Promise.reject(new Error('JSON parse error: ' + e.message));
console.error('fetch.json parse error:', e && e.message ? e.message : e);
return Promise.resolve(null);
}
}
};

View file

@ -48,7 +48,8 @@ class ProfileManager @Inject constructor(
name: String,
avatarColorHex: String,
usesPrimaryAddons: Boolean = false,
usesPrimaryPlugins: Boolean = false
usesPrimaryPlugins: Boolean = false,
avatarId: String? = null
): Boolean {
val current = profiles.value
if (current.size >= 4) return false
@ -61,7 +62,8 @@ class ProfileManager @Inject constructor(
name = name.trim().ifEmpty { "Profile $nextId" },
avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons,
usesPrimaryPlugins = usesPrimaryPlugins
usesPrimaryPlugins = usesPrimaryPlugins,
avatarId = avatarId
)
factory.markProfileCreated(nextId)
profileDataStore.upsertProfile(profile)

View file

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

View file

@ -0,0 +1,356 @@
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
@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"
)
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)
}
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)
}
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) ->
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 ->
mutablePrefs.clear()
featureJson.forEach { (keyName, encodedValue) ->
applyEncodedPreference(mutablePrefs, keyName, encodedValue)
}
}
}
} 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)}}"
}
}
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)}}"
}
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): String {
return prefs.asMap()
.entries
.mapNotNull { (key, rawValue) ->
encodePreferenceValue(rawValue)?.let { encoded ->
key.name to encoded.toString()
}
}
.sortedBy { it.first }
.joinToString(separator = "|") { (key, value) -> "$key=$value" }
}
private fun buildFeatureSignature(featureJson: JsonObject): String {
return featureJson.entries
.sortedBy { it.key }
.joinToString(separator = "|") { (key, value) -> "$key=$value" }
}
private fun encodePreferenceValue(rawValue: Any?): JsonObject? {
return when (rawValue) {
is String -> buildJsonObject {
put("type", "string")
put("value", rawValue)
}
is Boolean -> buildJsonObject {
put("type", "boolean")
put("value", rawValue)
}
is Int -> buildJsonObject {
put("type", "int")
put("value", rawValue)
}
is Long -> buildJsonObject {
put("type", "long")
put("value", rawValue)
}
is Float -> buildJsonObject {
put("type", "float")
put("value", rawValue)
}
is Double -> buildJsonObject {
put("type", "double")
put("value", rawValue)
}
is Set<*> -> {
val allStrings = rawValue.all { it is String }
if (!allStrings) return null
buildJsonObject {
put("type", "string_set")
val values = rawValue.map { it as String }.sorted()
put("value", JsonArray(values.map { JsonPrimitive(it) }))
}
}
else -> null
}
}
private fun applyEncodedPreference(
mutablePrefs: androidx.datastore.preferences.core.MutablePreferences,
keyName: String,
encodedValue: JsonElement
) {
val obj = encodedValue as? JsonObject ?: return
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return
val value = obj["value"] ?: JsonNull
when (type) {
"string" -> {
val parsed = value.jsonPrimitive.contentOrNull ?: return
mutablePrefs[stringPreferencesKey(keyName)] = parsed
}
"boolean" -> {
val parsed = value.jsonPrimitive.contentOrNull?.toBooleanStrictOrNull() ?: return
mutablePrefs[booleanPreferencesKey(keyName)] = parsed
}
"int" -> {
val parsed = value.jsonPrimitive.intOrNull ?: return
mutablePrefs[intPreferencesKey(keyName)] = parsed
}
"long" -> {
val parsed = value.jsonPrimitive.longOrNull ?: return
mutablePrefs[longPreferencesKey(keyName)] = parsed
}
"float" -> {
val parsed = value.jsonPrimitive.floatOrNull ?: return
mutablePrefs[floatPreferencesKey(keyName)] = parsed
}
"double" -> {
val parsed = value.jsonPrimitive.doubleOrNull ?: return
mutablePrefs[doublePreferencesKey(keyName)] = parsed
}
"string_set" -> {
val parsed = value.jsonArray.mapNotNull { it.jsonPrimitive.contentOrNull }.toSet()
mutablePrefs[stringSetPreferencesKey(keyName)] = parsed
}
}
}
}

View file

@ -4,7 +4,9 @@ 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
@ -47,6 +49,7 @@ 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)
}
}
})
@ -78,7 +81,8 @@ class ProfileSyncService @Inject constructor(
name = entry.name,
avatarColorHex = entry.avatarColorHex,
usesPrimaryAddons = entry.usesPrimaryAddons,
usesPrimaryPlugins = entry.usesPrimaryPlugins
usesPrimaryPlugins = entry.usesPrimaryPlugins,
avatarId = entry.avatarId
)
}
@ -110,4 +114,73 @@ class ProfileSyncService @Inject constructor(
Result.failure(e)
}
}
suspend fun pullProfileLockStates(): Result<Map<Int, Boolean>> = withContext(Dispatchers.IO) {
try {
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_profile_locks")
}
val remote = response.decodeList<SupabaseProfileLockState>()
val result = remote.associate { it.profileIndex to it.pinEnabled }
Result.success(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to pull profile lock states", e)
Result.failure(e)
}
}
suspend fun setProfilePin(profileId: Int, pin: String, currentPin: String? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_pin", pin)
if (!currentPin.isNullOrBlank()) {
put("p_current_pin", currentPin)
}
}
withJwtRefreshRetry {
postgrest.rpc("set_profile_pin", params)
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to set profile PIN", e)
Result.failure(e)
}
}
suspend fun clearProfilePin(profileId: Int, currentPin: String? = null): Result<Unit> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
if (!currentPin.isNullOrBlank()) {
put("p_current_pin", currentPin)
}
}
withJwtRefreshRetry {
postgrest.rpc("clear_profile_pin", params)
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear profile PIN", e)
Result.failure(e)
}
}
suspend fun verifyProfilePin(profileId: Int, pin: String): Result<SupabaseProfilePinVerifyResult> = withContext(Dispatchers.IO) {
try {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_pin", pin)
}
val response = withJwtRefreshRetry {
postgrest.rpc("verify_profile_pin", params)
}
val decoded = response.decodeList<SupabaseProfilePinVerifyResult>().firstOrNull()
?: SupabaseProfilePinVerifyResult(unlocked = false, retryAfterSeconds = 0)
Result.success(decoded)
} catch (e: Exception) {
Log.e(TAG, "Failed to verify profile PIN", e)
Result.failure(e)
}
}
}

View file

@ -32,6 +32,7 @@ class StartupSyncService @Inject constructor(
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,
@ -46,10 +47,15 @@ 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 {
@ -57,15 +63,23 @@ class StartupSyncService @Inject constructor(
when (state) {
is AuthState.FullAccount -> {
val force = forceSyncRequested
val started = scheduleStartupPull(state.userId, force = force)
val includeProfileSettings = if (force) forceSyncIncludesProfileSettings else true
val started = scheduleStartupPull(
userId = state.userId,
force = force,
includeProfileSettings = includeProfileSettings
)
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
}
@ -73,11 +87,16 @@ class StartupSyncService @Inject constructor(
}
}
fun requestSyncNow() {
fun requestSyncNow(includeProfileSettings: Boolean = true) {
forceSyncRequested = true
forceSyncIncludesProfileSettings = forceSyncIncludesProfileSettings || includeProfileSettings
when (val state = authManager.authState.value) {
is AuthState.FullAccount -> {
val started = scheduleStartupPull(state.userId, force = true)
val started = scheduleStartupPull(
userId = state.userId,
force = true,
includeProfileSettings = includeProfileSettings
)
if (started) forceSyncRequested = false
}
else -> Unit
@ -89,28 +108,40 @@ class StartupSyncService @Inject constructor(
return "${userId}_p${profileId}"
}
private fun scheduleStartupPull(userId: String, force: Boolean = false): Boolean {
private fun scheduleStartupPull(
userId: String,
force: Boolean = false,
includeProfileSettings: Boolean = true
): Boolean {
val key = pullKey(userId)
if (!force && lastPulledKey == key) return false
if (
!force &&
lastPulledKey == key &&
(!includeProfileSettings || lastPulledIncludedProfileSettings)
) {
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
if (force) {
pendingResyncKey = key
pendingResyncIncludesProfileSettings =
pendingResyncIncludesProfileSettings || includeProfileSettings
}
return false
}
startupPullJob = scope.launch {
val maxAttempts = 3
var syncCompleted = false
repeat(maxAttempts) { index ->
val attempt = index + 1
Log.d(TAG, "Startup sync attempt $attempt/$maxAttempts for key=$key")
val result = pullRemoteData()
for (attempt in 1..maxAttempts) {
val result = pullRemoteData(includeProfileSettings = includeProfileSettings)
if (result.isSuccess) {
lastPulledKey = key
Log.d(TAG, "Startup sync completed for key=$key")
lastPulledIncludedProfileSettings = includeProfileSettings
syncCompleted = true
return@repeat
break
}
Log.w(TAG, "Startup sync attempt $attempt failed for key=$key", result.exceptionOrNull())
@ -121,17 +152,26 @@ class StartupSyncService @Inject constructor(
val resyncKey = pendingResyncKey
if (resyncKey != null) {
val resyncIncludesProfileSettings = pendingResyncIncludesProfileSettings
pendingResyncKey = null
if (!syncCompleted || resyncKey != lastPulledKey) {
Log.d(TAG, "Running pending re-sync for key=$resyncKey")
scheduleStartupPull(userId, force = true)
pendingResyncIncludesProfileSettings = false
if (
!syncCompleted ||
resyncKey != lastPulledKey ||
(resyncIncludesProfileSettings && !lastPulledIncludedProfileSettings)
) {
scheduleStartupPull(
userId = userId,
force = true,
includeProfileSettings = resyncIncludesProfileSettings
)
}
}
}
return true
}
private suspend fun pullRemoteData(): Result<Unit> {
private suspend fun pullRemoteData(includeProfileSettings: Boolean): Result<Unit> {
try {
val profileId = profileManager.activeProfileId.value
Log.d(TAG, "Pulling remote data for profile $profileId")
@ -140,6 +180,18 @@ 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 }
@ -170,7 +222,11 @@ class StartupSyncService @Inject constructor(
val isPrimaryProfile = profileManager.activeProfileId.value == 1
val isTraktConnected = isPrimaryProfile && traktAuthDataStore.isAuthenticated.first()
Log.d(TAG, "Watch progress sync: isTraktConnected=$isTraktConnected isPrimaryProfile=$isPrimaryProfile")
val shouldUseSupabaseWatchProgressSync = watchProgressSyncService.shouldUseSupabaseWatchProgressSync()
Log.d(
TAG,
"Watch progress sync: isTraktConnected=$isTraktConnected isPrimaryProfile=$isPrimaryProfile shouldUseSupabaseWatchProgressSync=$shouldUseSupabaseWatchProgressSync"
)
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;
@ -211,6 +267,29 @@ class StartupSyncService @Inject constructor(
} finally {
watchProgressRepository.isSyncingFromRemote = false
}
} else if (shouldUseSupabaseWatchProgressSync) {
try {
val remoteWatchedItems = watchedItemsSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled ${remoteWatchedItems.size} watched items from remote")
watchedItemsPreferences.replaceWithRemoteItems(remoteWatchedItems)
watchProgressRepository.hasCompletedInitialWatchedItemsPull = true
Log.d(TAG, "Reconciled local watched items with ${remoteWatchedItems.size} remote items")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull watched items, continuing with Trakt library mode", e)
}
watchProgressRepository.isSyncingFromRemote = true
try {
val remoteEntries = watchProgressSyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled ${remoteEntries.size} watch progress entries from remote")
watchProgressPreferences.mergeRemoteEntries(remoteEntries.toMap())
watchProgressRepository.hasCompletedInitialPull = true
Log.d(TAG, "Merged local watch progress with ${remoteEntries.size} remote entries")
} catch (e: Exception) {
Log.e(TAG, "Failed to pull watch progress while Trakt is connected, continuing", e)
} finally {
watchProgressRepository.isSyncingFromRemote = false
}
} else {
Log.d(TAG, "Skipping watch progress & library sync (Trakt connected)")
}

View file

@ -4,6 +4,8 @@ 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
@ -27,6 +29,7 @@ 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 {
@ -38,10 +41,15 @@ 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 (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress delete")
if (!shouldUseSupabaseWatchProgressSync()) {
return@withContext Result.success(Unit)
}
@ -77,13 +85,15 @@ class WatchProgressSyncService @Inject constructor(
*/
suspend fun pushToRemote(): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress push")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watch progress push")
return@withContext Result.success(Unit)
}
val rawEntries = watchProgressPreferences.getAllRawEntries()
val entries = canonicalizeForRemote(rawEntries)
val entries = canonicalizeForRemote(rawEntries).filterValues { progress ->
!(progress.position <= 1L && progress.duration <= 1L && progress.duration > 0L)
}
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}")
@ -123,8 +133,8 @@ class WatchProgressSyncService @Inject constructor(
suspend fun pushSingleToRemote(key: String, progress: WatchProgress): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping single watch progress push")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping single watch progress push")
return@withContext Result.success(Unit)
}
@ -166,8 +176,8 @@ class WatchProgressSyncService @Inject constructor(
*/
suspend fun pullFromRemote(): Result<List<Pair<String, WatchProgress>>> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watch progress pull")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watch progress pull")
return@withContext Result.success(emptyList())
}

View file

@ -4,6 +4,8 @@ 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
@ -20,6 +22,7 @@ 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(
@ -27,6 +30,7 @@ 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 {
@ -38,10 +42,16 @@ 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 (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watched items push")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watched items push")
return@withContext Result.success(Unit)
}
@ -80,35 +90,81 @@ class WatchedItemsSyncService @Inject constructor(
suspend fun pullFromRemote(): Result<List<WatchedItem>> = withContext(Dispatchers.IO) {
try {
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Trakt connected, skipping watched items pull")
if (!shouldUseSupabaseWatchProgressSync()) {
Log.d(TAG, "Using Trakt watch progress, skipping watched items pull")
return@withContext Result.success(emptyList())
}
val profileId = profileManager.activeProfileId.value
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_watched_items", params)
}
val remote = response.decodeList<SupabaseWatchedItem>()
val allItems = mutableListOf<WatchedItem>()
var page = 1
Log.d(TAG, "pullFromRemote: fetched ${remote.size} watched items from Supabase for profile $profileId")
while (true) {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_page", page)
put("p_page_size", WATCHED_ITEMS_PAGE_SIZE)
}
val response = withJwtRefreshRetry {
postgrest.rpc("sync_pull_watched_items", params)
}
val remote = response.decodeList<SupabaseWatchedItem>()
Result.success(remote.map { entry ->
WatchedItem(
contentId = entry.contentId,
contentType = entry.contentType,
title = entry.title,
season = entry.season,
episode = entry.episode,
watchedAt = entry.watchedAt
)
})
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++
}
Log.d(TAG, "pullFromRemote: fetched ${allItems.size} total watched items from Supabase for profile $profileId")
Result.success(allItems)
} 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)
}
}
}

View file

@ -1,7 +1,9 @@
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
@ -18,13 +20,14 @@ 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 const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY
@Singleton
class TmdbMetadataService @Inject constructor(
@ -35,6 +38,9 @@ class TmdbMetadataService @Inject constructor(
private val episodeCache = ConcurrentHashMap<String, 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,
@ -106,10 +112,11 @@ 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.name?.trim()?.takeIf { name -> name.isNotBlank() } }
?.mapNotNull { it.iso31661?.trim()?.uppercase()?.takeIf { code -> code.isNotBlank() } }
?.takeIf { it.isNotEmpty() }
?: details?.originCountry?.takeIf { it.isNotEmpty() }
val language = details?.originalLanguage?.takeIf { it.isNotBlank() }
@ -120,7 +127,8 @@ class TmdbMetadataService @Inject constructor(
val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(company.logoPath, size = "w300")
logo = buildImageUrl(company.logoPath, size = "w300"),
tmdbId = company.id
)
}
val networks = details?.networks
@ -129,22 +137,19 @@ class TmdbMetadataService @Inject constructor(
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(network.logoPath, size = "w300")
logo = buildImageUrl(network.logoPath, size = "w300"),
tmdbId = network.id
)
}
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
?.sortedWith(
compareByDescending<com.nuvio.tv.data.remote.api.TmdbImage> {
it.iso6391 == normalizedLanguage.substringBefore("-")
}
.thenByDescending { it.iso6391 == "en" }
.thenByDescending { it.iso6391 == null }
)
?.firstOrNull()
?.filePath
val logoPath = images?.logos?.let {
selectBestLocalizedImagePath(it, normalizedLanguage)
}
val logo = buildImageUrl(logoPath, size = "w500")
@ -259,7 +264,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
productionCompanies.isEmpty() && networks.isEmpty() && ageRating == null && status == null
) {
return@withContext null
}
@ -282,8 +287,11 @@ class TmdbMetadataService @Inject constructor(
productionCompanies = productionCompanies,
networks = networks,
ageRating = ageRating,
status = status,
countries = countries,
language = language
language = language,
collectionId = collectionId,
collectionName = collectionName
)
enrichmentCache[cacheKey] = enrichment
enrichment
@ -406,7 +414,20 @@ class TmdbMetadataService @Inject constructor(
val backdrop = buildImageUrl(localizedBackdropPath ?: rec.backdropPath, size = "w1280")
val fallbackPoster = buildImageUrl(rec.posterPath, size = "w780")
val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4)
val releaseInfo = if (recTmdbType == "tv") {
val startYear = rec.firstAirDate?.take(4)
if (startYear != null) {
val tvDetails = runCatching {
tmdbApi.getTvDetails(rec.id, TMDB_API_KEY, normalizedLanguage).body()
}.getOrNull()
val status = tvDetails?.status
val endYear = tvDetails?.lastAirDate?.take(4)
buildShowYearRange(startYear, endYear, status)
} else null
} else {
rec.releaseDate?.take(4)
}
MetaPreview(
id = "tmdb:${rec.id}",
@ -433,17 +454,385 @@ 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 {
return language
val raw = language
?.trim()
?.takeIf { it.isNotBlank() }
?.replace('_', '-')
?: "en"
?: 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
}
}
private fun selectBestLocalizedImagePath(
@ -452,9 +841,12 @@ 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 == normalizedLanguage }
compareByDescending<TmdbImage> { it.iso6391 == languageCode && it.iso31661 == regionCode }
.thenByDescending { it.iso6391 == languageCode && it.iso31661 == null }
.thenByDescending { it.iso6391 == languageCode }
.thenByDescending { it.iso6391 == "en" }
.thenByDescending { it.iso6391 == null }
@ -463,27 +855,47 @@ 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
preferCrewCredits: Boolean? = null,
language: String = "en"
): PersonDetail? =
withContext(Dispatchers.IO) {
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}"
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}:$normalizedLanguage"
personCache[cacheKey]?.let { return@withContext it }
try {
val (person, credits) = coroutineScope {
val personDeferred = async {
tmdbApi.getPersonDetails(personId, TMDB_API_KEY).body()
tmdbApi.getPersonDetails(personId, TMDB_API_KEY, normalizedLanguage).body()
}
val creditsDeferred = async {
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY).body()
tmdbApi.getPersonCombinedCredits(personId, TMDB_API_KEY, normalizedLanguage).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())
@ -505,7 +917,7 @@ class TmdbMetadataService @Inject constructor(
val detail = PersonDetail(
tmdbId = person.id,
name = person.name ?: "Unknown",
biography = person.biography?.takeIf { it.isNotBlank() },
biography = biography,
birthday = person.birthday?.takeIf { it.isNotBlank() },
deathday = person.deathday?.takeIf { it.isNotBlank() },
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
@ -699,8 +1111,11 @@ 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 language: String?,
val collectionId: Int?,
val collectionName: String?
)
data class TmdbEpisodeEnrichment(
@ -711,6 +1126,58 @@ data class TmdbEpisodeEnrichment(
val runtimeMinutes: Int?
)
enum class TmdbEntityKind(val routeValue: String) {
COMPANY("company"),
NETWORK("network");
companion object {
fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase(Locale.US)) {
"network" -> NETWORK
else -> COMPANY
}
}
}
enum class TmdbEntityMediaType(val value: String) {
MOVIE("movie"),
TV("tv")
}
enum class TmdbEntityRailType(val value: String) {
POPULAR("popular"),
TOP_RATED("top_rated"),
RECENT("recent")
}
data class TmdbEntityHeader(
val id: Int,
val kind: TmdbEntityKind,
val name: String,
val logo: String?,
val originCountry: String?,
val secondaryLabel: String?,
val description: String?
)
data class TmdbEntityRail(
val mediaType: TmdbEntityMediaType,
val railType: TmdbEntityRailType,
val items: List<MetaPreview>,
val currentPage: Int = 1,
val hasMore: Boolean = false,
val isLoading: Boolean = false
)
data class TmdbEntityBrowseData(
val header: TmdbEntityHeader,
val rails: List<TmdbEntityRail>
)
data class TmdbEntityRailPageResult(
val items: List<MetaPreview>,
val hasMore: Boolean
)
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
val title = name?.takeIf { it.isNotBlank() }
val overview = overview?.takeIf { it.isNotBlank() }

View file

@ -1,6 +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.Dispatchers
import kotlinx.coroutines.sync.Mutex
@ -11,7 +12,7 @@ import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "TmdbService"
private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
private val TMDB_API_KEY = BuildConfig.TMDB_API_KEY
/**
* Service to handle TMDB ID conversions and lookups.

View file

@ -10,6 +10,16 @@ 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 {

View file

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

View file

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

View file

@ -26,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 = 1
private const val MIN_FOCUSED_POSTER_BACKDROP_EXPAND_DELAY_SECONDS = 0
}
private fun store(profileId: Int = profileManager.activeProfileId.value) =
@ -60,9 +60,12 @@ 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 fun <T> profileFlow(extract: (prefs: androidx.datastore.preferences.core.Preferences) -> T): Flow<T> =
profileManager.activeProfileId.flatMapLatest { pid ->
@ -129,6 +132,10 @@ 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
}
@ -191,6 +198,10 @@ 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
}
@ -203,6 +214,10 @@ class LayoutPreferenceDataStore @Inject constructor(
prefs[hideUnreleasedContentKey] ?: false
}
val showFullReleaseDate: Flow<Boolean> = profileFlow { prefs ->
prefs[showFullReleaseDateKey] ?: true
}
suspend fun setLayout(layout: HomeLayout) {
store().edit { prefs ->
val hadChosenLayout = prefs[hasChosenKey] ?: false
@ -288,6 +303,12 @@ 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
@ -382,6 +403,12 @@ 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
@ -400,6 +427,12 @@ class LayoutPreferenceDataStore @Inject constructor(
}
}
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 {

View file

@ -2,6 +2,7 @@ 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
@ -28,6 +29,19 @@ 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 ->

View file

@ -19,6 +19,7 @@ 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
@ -28,52 +29,90 @@ 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("es", "Spanish"),
SubtitleLanguage("et", "Estonian"),
SubtitleLanguage("tl", "Filipino"),
SubtitleLanguage("fi", "Finnish"),
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("ru", "Russian"),
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("ru", "Russian"),
SubtitleLanguage("sr", "Serbian"),
SubtitleLanguage("si", "Sinhala"),
SubtitleLanguage("sk", "Slovak"),
SubtitleLanguage("sl", "Slovenian"),
SubtitleLanguage("sr", "Serbian"),
SubtitleLanguage("es", "Spanish"),
SubtitleLanguage("es-419", "Spanish (Latin America)"),
SubtitleLanguage("sw", "Swahili"),
SubtitleLanguage("sv", "Swedish"),
SubtitleLanguage("ta", "Tamil"),
SubtitleLanguage("te", "Telugu"),
SubtitleLanguage("ml", "Malayalam"),
SubtitleLanguage("bn", "Bengali"),
SubtitleLanguage("mr", "Marathi"),
SubtitleLanguage("gu", "Gujarati"),
SubtitleLanguage("kn", "Kannada"),
SubtitleLanguage("pa", "Punjabi")
SubtitleLanguage("th", "Thai"),
SubtitleLanguage("tr", "Turkish"),
SubtitleLanguage("uk", "Ukrainian"),
SubtitleLanguage("ur", "Urdu"),
SubtitleLanguage("uz", "Uzbek"),
SubtitleLanguage("vi", "Vietnamese"),
SubtitleLanguage("cy", "Welsh"),
SubtitleLanguage("zu", "Zulu")
)
/**
@ -126,7 +165,10 @@ 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 pauseOverlayEnabled: Boolean = true,
val osdClockEnabled: Boolean = true,
@ -135,6 +177,7 @@ data class PlayerSettings(
val mapDV7ToHevc: Boolean = false,
// 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,
@ -142,13 +185,16 @@ data class PlayerSettings(
val streamAutoPlaySelectedPlugins: Set<String> = emptySet(),
val streamAutoPlayRegex: String = "",
val streamAutoPlayNextEpisodeEnabled: Boolean = false,
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = false,
val streamAutoPlayPreferBingeGroupForNextEpisode: Boolean = true,
val streamAutoPlayTimeoutSeconds: Int = 3,
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
val nextEpisodeThresholdPercent: Float = 98f,
val nextEpisodeThresholdPercent: Float = 99f,
val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f,
val streamReuseLastLinkEnabled: Boolean = false,
val streamReuseLastLinkCacheHours: Int = 24,
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE
val subtitleOrganizationMode: SubtitleOrganizationMode = SubtitleOrganizationMode.NONE,
val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES,
val resizeMode: Int = 0
)
enum class StreamAutoPlayMode {
@ -180,6 +226,12 @@ enum class SubtitleOrganizationMode {
BY_ADDON
}
enum class AddonSubtitleStartupMode {
FAST_STARTUP,
PREFERRED_ONLY,
ALL_SUBTITLES
}
enum class PlayerPreference {
INTERNAL,
EXTERNAL,
@ -205,6 +257,8 @@ 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) =
@ -223,7 +277,10 @@ 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 pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled")
private val osdClockEnabledKey = booleanPreferencesKey("osd_clock_enabled")
@ -231,6 +288,7 @@ class PlayerSettingsDataStore @Inject constructor(
private val mapDV7ToHevcKey = booleanPreferencesKey("map_dv7_to_hevc")
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")
@ -238,6 +296,7 @@ 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")
@ -246,6 +305,8 @@ 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")
@ -304,6 +365,19 @@ 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 =
@ -341,9 +415,16 @@ 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,
pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true,
osdClockEnabled = prefs[osdClockEnabledKey] ?: true,
@ -356,6 +437,7 @@ class PlayerSettingsDataStore @Inject constructor(
} else {
FrameRateMatchingMode.OFF
},
resolutionMatchingEnabled = prefs[resolutionMatchingEnabledKey] ?: false,
streamAutoPlayMode = prefs[streamAutoPlayModeKey]?.let {
runCatching { StreamAutoPlayMode.valueOf(it) }.getOrDefault(StreamAutoPlayMode.MANUAL)
} ?: StreamAutoPlayMode.MANUAL,
@ -367,14 +449,15 @@ class PlayerSettingsDataStore @Inject constructor(
streamAutoPlayRegex = prefs[streamAutoPlayRegexKey] ?: "",
streamAutoPlayNextEpisodeEnabled = prefs[streamAutoPlayNextEpisodeEnabledKey] ?: false,
streamAutoPlayPreferBingeGroupForNextEpisode =
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: false,
prefs[streamAutoPlayPreferBingeGroupForNextEpisodeKey] ?: true,
streamAutoPlayTimeoutSeconds = (prefs[streamAutoPlayTimeoutSecondsKey] ?: 3).coerceIn(0, 11),
nextEpisodeThresholdMode = prefs[nextEpisodeThresholdModeKey]?.let {
runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrDefault(NextEpisodeThresholdMode.PERCENTAGE)
} ?: NextEpisodeThresholdMode.PERCENTAGE,
nextEpisodeThresholdPercent = normalizeHalfStep(
value = prefs[nextEpisodeThresholdPercentKey]
?: prefs[nextEpisodeThresholdPercentLegacyKey]?.toFloat()
?: 98f,
?: 99f,
min = 97f,
max = 99.5f
),
@ -388,6 +471,8 @@ 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"
@ -464,6 +549,27 @@ 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(
@ -472,6 +578,19 @@ 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
@ -503,6 +622,12 @@ 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
@ -551,6 +676,12 @@ 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
@ -600,6 +731,18 @@ 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
@ -609,6 +752,15 @@ 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 normalizeSelectableLanguageCode(language: String): String {
val code = language.trim().lowercase()
return when (code) {
@ -619,6 +771,16 @@ class PlayerSettingsDataStore @Inject constructor(
}
}
private fun normalizeSecondaryAudioLanguageCode(language: String): String? {
val normalized = normalizeSelectableLanguageCode(language)
return when (normalized) {
AudioLanguageOption.DEFAULT,
AudioLanguageOption.DEVICE,
SUBTITLE_LANGUAGE_FORCED -> null
else -> normalized
}
}
suspend fun setMapDV7ToHevc(enabled: Boolean) {
store().edit { prefs ->
prefs[mapDV7ToHevcKey] = enabled

View file

@ -67,9 +67,13 @@ 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).also { it.mkdirs() }
return File(context.filesDir, dirName)
}
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 ->
@ -170,21 +174,30 @@ class PluginDataStore @Inject constructor(
return File(codeDir, "$scraperId.js")
}
fun saveScraperCode(scraperId: String, code: String) {
getScraperCodeFile(scraperId).writeText(code)
suspend fun saveScraperCode(scraperId: String, code: String) {
val dir = ensureCodeDir()
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
File(dir, "$scraperId.js").writeText(code)
}
}
fun getScraperCode(scraperId: String): String? {
val file = getScraperCodeFile(scraperId)
return if (file.exists()) file.readText() else null
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 deleteScraperCode(scraperId: String) {
getScraperCodeFile(scraperId).delete()
suspend fun deleteScraperCode(scraperId: String) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
File(codeDir, "$scraperId.js").delete()
}
}
fun clearAllScraperCode() {
codeDir.listFiles()?.forEach { it.delete() }
suspend fun clearAllScraperCode() {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
codeDir.listFiles()?.forEach { it.delete() }
}
}
// Per-scraper settings

View file

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

View file

@ -15,8 +15,9 @@ data class CachedStreamLink(
val streamName: String,
val headers: Map<String, String>,
val cachedAtMs: Long,
val rememberedAudioLanguage: String? = null,
val rememberedAudioName: String? = null
val filename: String? = null,
val videoHash: String? = null,
val videoSize: Long? = null
)
@Singleton
@ -36,16 +37,18 @@ class StreamLinkCacheDataStore @Inject constructor(
url: String,
streamName: String,
headers: Map<String, String>?,
rememberedAudioLanguage: String? = null,
rememberedAudioName: String? = null
filename: String? = null,
videoHash: String? = null,
videoSize: Long? = null
) {
val payload = JSONObject().apply {
put("url", url)
put("streamName", streamName)
put("cachedAtMs", System.currentTimeMillis())
put("headers", JSONObject(headers ?: emptyMap<String, String>()))
put("rememberedAudioLanguage", rememberedAudioLanguage)
put("rememberedAudioName", rememberedAudioName)
put("filename", filename)
put("videoHash", videoHash)
videoSize?.let { put("videoSize", it) }
}.toString()
store().edit { prefs ->
@ -81,8 +84,9 @@ class StreamLinkCacheDataStore @Inject constructor(
streamName = streamName,
headers = headers,
cachedAtMs = cachedAtMs,
rememberedAudioLanguage = json.optString("rememberedAudioLanguage", "").ifBlank { null },
rememberedAudioName = json.optString("rememberedAudioName", "").ifBlank { null }
filename = json.optString("filename", "").ifBlank { null },
videoHash = json.optString("videoHash", "").ifBlank { null },
videoSize = json.optLong("videoSize", -1L).takeIf { it >= 0L }
)
}.getOrNull()

View file

@ -3,6 +3,7 @@ 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
@ -23,6 +24,7 @@ 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 ->
@ -35,9 +37,26 @@ class ThemeDataStore @Inject constructor(
}
}
val selectedFont: Flow<AppFont> = profileManager.activeProfileId.flatMapLatest { pid ->
factory.get(pid, FEATURE).data.map { prefs ->
val fontName = prefs[fontKey] ?: AppFont.INTER.name
try {
AppFont.valueOf(fontName)
} catch (e: IllegalArgumentException) {
AppFont.INTER
}
}
}
suspend fun setTheme(theme: AppTheme) {
store().edit { prefs ->
prefs[themeKey] = theme.name
}
}
suspend fun setFont(font: AppFont) {
store().edit { prefs ->
prefs[fontKey] = font.name
}
}
}

View file

@ -24,6 +24,8 @@ 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")
@ -33,11 +35,14 @@ class TmdbSettingsDataStore @Inject constructor(
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,
@ -46,7 +51,8 @@ class TmdbSettingsDataStore @Inject constructor(
useProductions = prefs[useProductionsKey] ?: true,
useNetworks = prefs[useNetworksKey] ?: true,
useEpisodes = prefs[useEpisodesKey] ?: true,
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true
useMoreLikeThis = prefs[useMoreLikeThisKey] ?: true,
useCollections = prefs[useCollectionsKey] ?: true
)
}
}
@ -55,6 +61,14 @@ 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" } }
}
@ -90,4 +104,8 @@ class TmdbSettingsDataStore @Inject constructor(
suspend fun setUseMoreLikeThis(enabled: Boolean) {
store().edit { it[useMoreLikeThisKey] = enabled }
}
suspend fun setUseCollections(enabled: Boolean) {
store().edit { it[useCollectionsKey] = enabled }
}
}

View file

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

View file

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

View file

@ -22,6 +22,13 @@ private val Context.traktAuthDataStore: DataStore<Preferences> by preferencesDat
name = "trakt_auth_store"
)
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)
}
data class TraktAuthState(
val accessToken: String? = null,
val refreshToken: String? = null,
@ -66,7 +73,7 @@ class TraktAuthDataStore @Inject constructor(
refreshToken = preferences[refreshTokenKey],
tokenType = preferences[tokenTypeKey],
createdAt = preferences[createdAtKey],
expiresIn = preferences[expiresInKey],
expiresIn = preferences[expiresInKey]?.let(::normalizeTraktTokenLifetimeSeconds),
username = preferences[usernameKey],
userSlug = preferences[userSlugKey],
deviceCode = preferences[deviceCodeKey],
@ -92,7 +99,7 @@ class TraktAuthDataStore @Inject constructor(
preferences[refreshTokenKey] = token.refreshToken
preferences[tokenTypeKey] = token.tokenType
preferences[createdAtKey] = token.createdAt
preferences[expiresInKey] = token.expiresIn
preferences[expiresInKey] = normalizeTraktTokenLifetimeSeconds(token.expiresIn)
}
}
@ -155,4 +162,3 @@ class TraktAuthDataStore @Inject constructor(
}
}
}

View file

@ -3,15 +3,29 @@ 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
@ -21,6 +35,8 @@ 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
}
@ -31,6 +47,8 @@ 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 ->
@ -52,6 +70,18 @@ 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)
@ -74,9 +104,33 @@ class TraktSettingsDataStore @Inject constructor(
}
}
suspend fun removeDismissedNextUpKeysForContent(contentId: String) {
if (contentId.isBlank()) return
val prefix = "${contentId.trim()}|"
store().edit { prefs ->
val current = prefs[dismissedNextUpKeysKey] ?: emptySet()
val filtered = current.filterNot { it.startsWith(prefix) }
if (filtered.size != current.size) {
prefs[dismissedNextUpKeysKey] = filtered.toSet()
}
}
}
suspend fun setShowUnairedNextUp(enabled: Boolean) {
store().edit { prefs ->
prefs[showUnairedNextUpKey] = enabled
}
}
suspend fun setShowMetaComments(enabled: Boolean) {
store().edit { prefs ->
prefs[showMetaCommentsKey] = enabled
}
}
suspend fun setWatchProgressSource(source: WatchProgressSource) {
store().edit { prefs ->
prefs[watchProgressSourceKey] = source.name
}
}
}

View file

@ -32,13 +32,6 @@ 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)
@ -129,19 +122,19 @@ class WatchProgressPreferences @Inject constructor(
store().edit { preferences ->
val json = preferences[watchProgressKey] ?: "{}"
val map = parseProgressMap(json).toMutableMap()
val key = createKey(progress)
map[key] = progress
upsertProgressEntries(map, listOf(progress))
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)
}
}
val pruned = pruneOldItems(map)
preferences[watchProgressKey] = gson.toJson(pruned)
}
}
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)
}
@ -186,8 +179,20 @@ class WatchProgressPreferences @Inject constructor(
* 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 = progress.duration,
position = effectiveDuration,
duration = effectiveDuration,
lastWatched = System.currentTimeMillis()
)
saveProgress(completedProgress)
@ -224,7 +229,7 @@ class WatchProgressPreferences @Inject constructor(
for ((key, remote) in remoteEntries) {
val existing = local[key]
if (existing == null || remote.lastWatched > existing.lastWatched) {
local[key] = remote
local[key] = mergeDisplayMetadata(remote, existing)
Log.d("WatchProgressPrefs", " merged key=$key (existing=${existing != null})")
} else {
Log.d("WatchProgressPrefs", " skipped key=$key (local is newer)")
@ -246,7 +251,10 @@ class WatchProgressPreferences @Inject constructor(
Log.w(TAG, "replaceWithRemoteEntries: remote empty while local has ${current.size} entries; preserving local watch progress")
return@edit
}
val pruned = pruneOldItems(remoteEntries.toMutableMap())
val merged = remoteEntries.mapValues { (key, remote) ->
mergeDisplayMetadata(remote, current[key])
}.toMutableMap()
val pruned = pruneOldItems(merged)
Log.d("WatchProgressPrefs", "replaceWithRemoteEntries: ${pruned.size} entries after prune, writing to DataStore")
preferences[watchProgressKey] = gson.toJson(pruned)
}
@ -269,6 +277,37 @@ class WatchProgressPreferences @Inject constructor(
}
}
private fun upsertProgressEntries(
map: MutableMap<String, WatchProgress>,
progressList: List<WatchProgress>
) {
progressList.forEach { progress ->
val key = createKey(progress)
map[key] = progress
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)
}
}
}
}
private fun mergeDisplayMetadata(remote: WatchProgress, existing: WatchProgress?): WatchProgress {
if (existing == null) return remote
return remote.copy(
name = remote.name.takeIf { it.isNotBlank() } ?: existing.name,
poster = remote.poster ?: existing.poster,
backdrop = remote.backdrop ?: existing.backdrop,
logo = remote.logo ?: existing.logo,
episodeTitle = remote.episodeTitle ?: existing.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.
@ -374,75 +413,6 @@ class WatchProgressPreferences @Inject constructor(
}
private fun pruneOldItems(map: MutableMap<String, WatchProgress>): Map<String, WatchProgress> {
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
return map
}
}

View file

@ -29,7 +29,7 @@ class WatchedItemsPreferences @Inject constructor(
private val gson = Gson()
private val watchedItemsKey = stringSetPreferencesKey("watched_items")
private val allItems: Flow<List<WatchedItem>> = profileManager.activeProfileId.flatMapLatest { pid ->
internal 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,6 +56,13 @@ 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()
@ -126,4 +133,10 @@ class WatchedItemsPreferences @Inject constructor(
.toSet()
}
}
suspend fun clearAll() {
store().edit { preferences ->
preferences.remove(watchedItemsKey)
}
}
}

View file

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

View file

@ -1,12 +1,16 @@
package com.nuvio.tv.data.mapper
import com.nuvio.tv.data.remote.dto.AddonManifestDto
import com.nuvio.tv.data.remote.dto.AddonBehaviorHintsDto
import com.nuvio.tv.data.remote.dto.CatalogDescriptorDto
import com.nuvio.tv.data.remote.dto.StremioAddonsConfigDto
import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.AddonBehaviorHints
import com.nuvio.tv.domain.model.AddonResource
import com.nuvio.tv.domain.model.CatalogExtra
import com.nuvio.tv.domain.model.CatalogDescriptor
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.StremioAddonsConfig
fun AddonManifestDto.toDomain(baseUrl: String): Addon {
val manifestTypes = types.map { it.trim() }.filter { it.isNotEmpty() }
@ -16,11 +20,18 @@ fun AddonManifestDto.toDomain(baseUrl: String): Addon {
version = version,
description = description,
logo = logo,
background = background,
baseUrl = baseUrl,
catalogs = catalogs.map { it.toDomain() },
types = manifestTypes.map { ContentType.fromString(it) },
rawTypes = manifestTypes,
resources = parseResources(resources, manifestTypes)
resources = parseResources(resources, manifestTypes),
idPrefixes = idPrefixes,
behaviorHints = behaviorHints?.toDomain(),
stremioAddonsConfig = stremioAddonsConfig?.toDomain(),
manifestLanguage = language,
configVersion = configVersion,
timestamp = timestamp
)
}
@ -31,13 +42,12 @@ fun CatalogDescriptorDto.toDomain(): CatalogDescriptor {
rawType = manifestType,
id = id,
name = name,
extra = extra.orEmpty().map { dto ->
CatalogExtra(
name = dto.name,
isRequired = dto.isRequired ?: false,
options = dto.options
)
}
extra = parseCatalogExtras(extra),
pageSize = pageSize,
showInHome = showInHome == true,
hasExplicitShowInHome = showInHome != null,
extraSupported = extraSupported.orEmpty(),
extraRequired = extraRequired.orEmpty()
)
}
@ -67,3 +77,64 @@ private fun parseResources(resources: List<Any>, defaultTypes: List<String>): Li
}
}
}
private fun parseCatalogExtras(rawExtras: List<Any>?): List<CatalogExtra> {
return rawExtras.orEmpty().mapNotNull { raw ->
when (raw) {
is String -> {
val name = raw.trim().lowercase()
if (name.isBlank()) {
null
} else {
CatalogExtra(name = name)
}
}
is Map<*, *> -> {
val name = (raw["name"] as? String)?.trim()?.lowercase().orEmpty()
if (name.isBlank()) return@mapNotNull null
val isRequired = when (val required = raw["isRequired"]) {
is Boolean -> required
is String -> required.equals("true", ignoreCase = true)
is Number -> required.toInt() != 0
else -> false
}
val options = (raw["options"] as? List<*>)?.mapNotNull { option ->
when (option) {
null -> null
is String -> option
else -> option.toString()
}
}?.takeIf { it.isNotEmpty() }
CatalogExtra(
name = name,
isRequired = isRequired,
options = options,
defaultValue = (raw["default"] as? String)?.takeIf { it.isNotBlank() },
optionsLimit = when (val value = raw["optionsLimit"]) {
is Number -> value.toInt()
is String -> value.toIntOrNull()
else -> null
}
)
}
else -> null
}
}.distinct()
}
private fun AddonBehaviorHintsDto.toDomain(): AddonBehaviorHints {
return AddonBehaviorHints(
configurable = configurable,
configurationRequired = configurationRequired,
newEpisodeNotifications = newEpisodeNotifications
)
}
private fun StremioAddonsConfigDto.toDomain(): StremioAddonsConfig {
return StremioAddonsConfig(
issuer = issuer?.takeIf { it.isNotBlank() },
signature = signature?.takeIf { it.isNotBlank() }
)
}

View file

@ -18,6 +18,20 @@ fun MetaPreviewDto.toDomain(): MetaPreview {
description = description,
releaseInfo = releaseInfo,
imdbRating = imdbRating?.toFloatOrNull(),
genres = genres ?: emptyList()
genres = genres ?: emptyList(),
runtime = runtime,
status = status?.trim()?.takeIf { it.isNotBlank() },
released = released,
country = country,
imdbId = imdbId,
slug = slug,
landscapePoster = landscapePoster,
rawPosterUrl = rawPosterUrl,
director = coerceStringList(director),
writer = coerceStringList(writer).ifEmpty { coerceStringList(writers) },
links = links?.mapNotNull { it.toDomain() } ?: emptyList(),
behaviorHints = mapBehaviorHints(behaviorHints),
trailers = mapTrailers(trailers, trailerStreams),
trailerYtIds = collectTrailerYtIds(trailers, trailerStreams)
)
}

View file

@ -5,12 +5,20 @@ import com.nuvio.tv.data.remote.dto.MetaLinkDto
import com.nuvio.tv.data.remote.dto.VideoDto
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.Meta
import com.nuvio.tv.domain.model.MetaCastMember
import com.nuvio.tv.domain.model.MetaLink
import com.nuvio.tv.domain.model.PosterShape
import com.nuvio.tv.domain.model.Video
fun MetaDto.toDomain(episodeLabel: String = "Episode"): Meta {
val directorMembers = mapPeople(appExtras?.directors, roleFallback = "Director", forceRole = true)
val writerMembers = mapPeople(appExtras?.writers, roleFallback = "Writer", forceRole = true)
val castMembers = mapPeople(appExtras?.cast)
val directors = coerceStringList(director).ifEmpty { directorMembers.map { it.name } }
val writersList = coerceStringList(writer).ifEmpty { coerceStringList(writers) }
.ifEmpty { writerMembers.map { it.name } }
val castList = coerceStringList(cast).ifEmpty { castMembers.map { it.name } }
val trailersList = mapTrailers(trailers, trailerStreams)
return Meta(
id = id,
type = ContentType.fromString(type),
@ -20,50 +28,42 @@ fun MetaDto.toDomain(episodeLabel: String = "Episode"): Meta {
posterShape = PosterShape.fromString(posterShape),
background = background,
logo = logo,
imdbId = imdbId,
slug = slug,
released = released,
landscapePoster = landscapePoster,
description = description,
releaseInfo = releaseInfo,
status = status?.trim()?.takeIf { it.isNotBlank() },
imdbRating = imdbRating?.toFloatOrNull(),
genres = genres ?: emptyList(),
runtime = runtime,
director = coerceStringList(director),
writer = coerceStringList(writer).ifEmpty { coerceStringList(writers) },
cast = coerceStringList(cast),
castMembers = appExtras?.cast
.orEmpty()
.mapNotNull { castMember ->
val name = castMember.name.trim()
if (name.isBlank()) return@mapNotNull null
MetaCastMember(
name = name,
character = castMember.character?.takeIf { it.isNotBlank() },
photo = castMember.photo?.takeIf { it.isNotBlank() }
)
},
director = directors,
writer = writersList,
cast = castList,
castMembers = directorMembers + writerMembers + castMembers,
videos = videos?.map { it.toDomain(episodeLabel) } ?: emptyList(),
productionCompanies = emptyList(),
networks = emptyList(),
ageRating = null,
ageRating = appExtras?.certification?.takeIf { it.isNotBlank() },
country = country,
awards = awards,
language = language,
links = links?.mapNotNull { it.toDomain() } ?: emptyList()
links = links?.mapNotNull { it.toDomain() } ?: emptyList(),
trailerYtIds = trailersList.mapNotNull { it.ytId }.distinct(),
rawPosterUrl = rawPosterUrl,
behaviorHints = mapBehaviorHints(behaviorHints),
trailers = trailersList,
releaseDates = mapReleaseDates(appExtras?.releaseDates),
hasPoster = hasPoster,
hasBackground = hasBackground,
hasLandscapePoster = hasLandscapePoster,
hasLogo = hasLogo,
hasLinks = hasLinks,
hasVideos = hasVideos
)
}
private fun coerceStringList(value: Any?): List<String> {
return when (value) {
null -> emptyList()
is String -> listOf(value)
is List<*> -> value.filterIsInstance<String>()
is Map<*, *> -> {
// Some addons may return an object; try a couple common keys.
val name = value["name"] as? String
if (!name.isNullOrBlank()) listOf(name) else emptyList()
}
else -> emptyList()
}
}
fun VideoDto.toDomain(episodeLabel: String = "Episode"): Video {
return Video(
id = id,
@ -73,7 +73,9 @@ fun VideoDto.toDomain(episodeLabel: String = "Episode"): Video {
streams = streams?.map { it.toDomain(addonName = "Embedded Streams", addonLogo = null) } ?: emptyList(),
season = season,
episode = episode ?: number,
overview = overview ?: description
overview = overview ?: description,
runtime = parseEpisodeRuntimeMinutes(runtime),
available = available
)
}
@ -86,3 +88,16 @@ fun MetaLinkDto.toDomain(): MetaLink? {
)
}
}
private fun parseEpisodeRuntimeMinutes(runtime: String?): Int? {
val normalized = runtime?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val hourMatch = "(\\d+)\\s*h".toRegex().find(normalized)
val minuteMatch = "(\\d+)\\s*m(?:in)?".toRegex().find(normalized)
val hours = hourMatch?.groupValues?.getOrNull(1)?.toIntOrNull()
val minutes = minuteMatch?.groupValues?.getOrNull(1)?.toIntOrNull()
if (hours != null || minutes != null) {
return (hours ?: 0) * 60 + (minutes ?: 0)
}
return normalized.filter(Char::isDigit).toIntOrNull()
}

View file

@ -0,0 +1,112 @@
package com.nuvio.tv.data.mapper
import com.nuvio.tv.data.remote.dto.AppExtrasCastMemberDto
import com.nuvio.tv.data.remote.dto.MetaBehaviorHintsDto
import com.nuvio.tv.data.remote.dto.MetaReleaseDateCountryDto
import com.nuvio.tv.data.remote.dto.MetaReleaseDatesEnvelopeDto
import com.nuvio.tv.data.remote.dto.MetaTrailerDto
import com.nuvio.tv.data.remote.dto.TrailerStreamDto
import com.nuvio.tv.domain.model.MetaBehaviorHints
import com.nuvio.tv.domain.model.MetaCastMember
import com.nuvio.tv.domain.model.MetaReleaseDate
import com.nuvio.tv.domain.model.MetaReleaseDateCountry
import com.nuvio.tv.domain.model.MetaTrailer
internal fun coerceStringList(value: Any?): List<String> {
return when (value) {
null -> emptyList()
is String -> listOf(value)
is List<*> -> value.mapNotNull { entry ->
when (entry) {
is String -> entry
is Map<*, *> -> entry["name"] as? String
else -> null
}
}
is Map<*, *> -> {
val name = value["name"] as? String
if (!name.isNullOrBlank()) listOf(name) else emptyList()
}
else -> emptyList()
}.mapNotNull { it.trim().takeIf(String::isNotBlank) }
}
internal fun mapPeople(
people: List<AppExtrasCastMemberDto>?,
roleFallback: String? = null,
forceRole: Boolean = false
): List<MetaCastMember> {
return people.orEmpty().mapNotNull { person ->
val name = person.name.trim()
if (name.isBlank()) return@mapNotNull null
MetaCastMember(
name = name,
character = if (forceRole) roleFallback else person.character?.takeIf { it.isNotBlank() } ?: roleFallback,
photo = person.photo?.takeIf { it.isNotBlank() },
tmdbId = person.tmdbId
)
}
}
internal fun mapBehaviorHints(dto: MetaBehaviorHintsDto?): MetaBehaviorHints? {
if (dto == null) return null
return MetaBehaviorHints(
defaultVideoId = dto.defaultVideoId?.takeIf { it.isNotBlank() },
hasScheduledVideos = dto.hasScheduledVideos
)
}
internal fun mapTrailers(
trailers: List<MetaTrailerDto>?,
trailerStreams: List<TrailerStreamDto>?
): List<MetaTrailer> {
val fromTrailers = trailers.orEmpty().map {
MetaTrailer(
source = it.source?.takeIf(String::isNotBlank),
type = it.type?.takeIf(String::isNotBlank),
name = it.name?.takeIf(String::isNotBlank),
ytId = it.ytId?.takeIf(String::isNotBlank),
lang = it.lang?.takeIf(String::isNotBlank)
)
}
val existingYtIds = fromTrailers.mapNotNull { it.ytId }.toSet()
val fromStreams = trailerStreams.orEmpty().mapNotNull { stream ->
val ytId = stream.ytId?.takeIf(String::isNotBlank) ?: return@mapNotNull null
if (ytId in existingYtIds) return@mapNotNull null
MetaTrailer(ytId = ytId)
}
return fromTrailers + fromStreams
}
internal fun collectTrailerYtIds(
trailers: List<MetaTrailerDto>?,
trailerStreams: List<TrailerStreamDto>?
): List<String> {
return mapTrailers(trailers, trailerStreams)
.mapNotNull { it.ytId }
.distinct()
}
internal fun mapReleaseDates(
envelope: MetaReleaseDatesEnvelopeDto?
): List<MetaReleaseDateCountry> {
return envelope?.results.orEmpty().mapNotNull(::mapReleaseDateCountry)
}
private fun mapReleaseDateCountry(dto: MetaReleaseDateCountryDto): MetaReleaseDateCountry? {
val countryCode = dto.iso31661.trim()
if (countryCode.isBlank()) return null
return MetaReleaseDateCountry(
countryCode = countryCode,
releaseDates = dto.releaseDates.orEmpty().map {
MetaReleaseDate(
certification = it.certification?.takeIf(String::isNotBlank),
descriptors = it.descriptors.orEmpty(),
languageCode = it.iso6391?.takeIf(String::isNotBlank),
note = it.note?.takeIf(String::isNotBlank),
releaseDate = it.releaseDate?.takeIf(String::isNotBlank),
type = it.type
)
}
)
}

View file

@ -32,6 +32,22 @@ fun BehaviorHintsDto.toDomain(): StreamBehaviorHints = StreamBehaviorHints(
)
fun ProxyHeadersDto.toDomain(): ProxyHeaders = ProxyHeaders(
request = request,
response = response
request = sanitizeHeaderMap(request),
response = sanitizeHeaderMap(response)
)
private fun sanitizeHeaderMap(headers: Map<String, String>?): Map<String, String>? {
if (headers == null) return null
val raw: Map<*, *> = headers
if (raw.isEmpty()) return null
val sanitized = LinkedHashMap<String, String>(raw.size)
raw.forEach { (rawKey, rawValue) ->
val key = (rawKey as? String)?.trim().orEmpty()
val value = (rawValue as? String)?.trim().orEmpty()
if (key.isEmpty() || value.isEmpty()) return@forEach
if (key.equals("Range", ignoreCase = true)) return@forEach
sanitized[key] = value
}
return sanitized.takeIf { it.isNotEmpty() }
}

View file

@ -0,0 +1,14 @@
package com.nuvio.tv.data.remote.api
import com.nuvio.tv.data.remote.dto.DonationsResponseDto
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface DonationsApi {
@GET("api/donations")
suspend fun getDonations(
@Query("limit") limit: Int = 200
): Response<DonationsResponseDto>
}

View file

@ -1,5 +1,6 @@
package com.nuvio.tv.data.remote.api
import com.nuvio.tv.data.remote.dto.GitHubContributorDto
import com.nuvio.tv.data.remote.dto.GitHubReleaseDto
import retrofit2.Response
import retrofit2.http.GET
@ -12,4 +13,10 @@ interface GitHubReleaseApi {
@Path("owner") owner: String,
@Path("repo") repo: String
): Response<GitHubReleaseDto>
@GET("repos/{owner}/{repo}/contributors")
suspend fun getContributors(
@Path("owner") owner: String,
@Path("repo") repo: String
): Response<List<GitHubContributorDto>>
}

View file

@ -4,11 +4,17 @@ import com.nuvio.tv.data.remote.dto.mdblist.MDBListRatingRequestDto
import com.nuvio.tv.data.remote.dto.mdblist.MDBListRatingResponseDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface MDBListApi {
@GET("user")
suspend fun getUser(
@Query("apikey") apiKey: String
): Response<Unit>
@POST("rating/{mediaType}/{ratingType}")
suspend fun getRating(
@Path("mediaType") mediaType: String,

View file

@ -3,7 +3,10 @@ package com.nuvio.tv.data.remote.api
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
@ -73,21 +76,108 @@ data class AniSkipInterval(
// --- ARM API (IMDB -> MAL ID resolution) ---
interface ArmApi {
// /imdb?id=...&include=myanimelist,anilist,kitsu → List<ArmEntry> (one per season)
@GET("imdb")
suspend fun resolveByImdb(
suspend fun resolveImdbToAll(
@Query("id") imdbId: String,
@Query("include") include: String = "myanimelist"
@Query("include") include: String = "myanimelist,anilist,kitsu"
): Response<List<ArmEntry>>
// /ids?source=myanimelist&id=...&include=anilist → single ArmEntry
@GET("ids")
suspend fun resolveByKitsu(
suspend fun resolveMalToAnilist(
@Query("source") source: String = "myanimelist",
@Query("id") malId: String,
@Query("include") include: String = "anilist"
): Response<ArmEntry>
// /ids?source=myanimelist&id=...&include=imdb → single ArmEntry
@GET("ids")
suspend fun resolveMalToImdb(
@Query("source") source: String = "myanimelist",
@Query("id") malId: String,
@Query("include") include: String = "imdb"
): Response<ArmEntry>
// /ids?source=kitsu&id=...&include=myanimelist → single ArmEntry
@GET("ids")
suspend fun resolveKitsuToMal(
@Query("source") source: String = "kitsu",
@Query("id") kitsuId: String,
@Query("include") include: String = "myanimelist"
): Response<ArmEntry>
// /ids?source=kitsu&id=...&include=anilist → single ArmEntry
@GET("ids")
suspend fun resolveKitsuToAnilist(
@Query("source") source: String = "kitsu",
@Query("id") kitsuId: String,
@Query("include") include: String = "anilist"
): Response<ArmEntry>
// /ids?source=kitsu&id=...&include=imdb → single ArmEntry
@GET("ids")
suspend fun resolveKitsuToImdb(
@Query("source") source: String = "kitsu",
@Query("id") kitsuId: String,
@Query("include") include: String = "imdb"
): Response<ArmEntry>
}
@JsonClass(generateAdapter = true)
data class ArmEntry(
@Json(name = "myanimelist") val myanimelist: Int? = null
@Json(name = "myanimelist") val myanimelist: Int? = null,
@Json(name = "anilist") val anilist: Int? = null,
@Json(name = "kitsu") val kitsu: Int? = null,
@Json(name = "imdb") val imdb: String? = null
)
// --- Anime-Skip API (GraphQL) ---
interface AnimeSkipApi {
@POST("graphql")
suspend fun query(
@Header("X-Client-ID") clientId: String,
@Body body: AnimeSkipRequest
): Response<AnimeSkipResponse>
}
@JsonClass(generateAdapter = true)
data class AnimeSkipRequest(
@Json(name = "query") val query: String,
@Json(name = "variables") val variables: Map<String, String> = emptyMap()
)
@JsonClass(generateAdapter = true)
data class AnimeSkipResponse(
@Json(name = "data") val data: AnimeSkipData? = null
)
@JsonClass(generateAdapter = true)
data class AnimeSkipData(
@Json(name = "findShowsByExternalId") val findShowsByExternalId: List<AnimeSkipShow>? = null,
@Json(name = "findEpisodesByShowId") val findEpisodesByShowId: List<AnimeSkipEpisode>? = null
)
@JsonClass(generateAdapter = true)
data class AnimeSkipShow(
@Json(name = "id") val id: String
)
@JsonClass(generateAdapter = true)
data class AnimeSkipEpisode(
@Json(name = "season") val season: String? = null,
@Json(name = "number") val number: String? = null,
@Json(name = "timestamps") val timestamps: List<AnimeSkipTimestamp>? = null
)
@JsonClass(generateAdapter = true)
data class AnimeSkipTimestamp(
@Json(name = "at") val at: Double,
@Json(name = "type") val type: AnimeSkipTimestampType
)
@JsonClass(generateAdapter = true)
data class AnimeSkipTimestampType(
@Json(name = "name") val name: String
)

View file

@ -112,6 +112,13 @@ interface TmdbApi {
@Query("page") page: Int = 1
): Response<TmdbRecommendationsResponse>
@GET("collection/{collection_id}")
suspend fun getCollectionDetails(
@Path("collection_id") collectionId: Int,
@Query("api_key") apiKey: String,
@Query("language") language: String? = null
): Response<TmdbCollectionResponse>
@GET("tv/{tv_id}/season/{season_number}")
suspend fun getTvSeasonDetails(
@Path("tv_id") tvId: Int,
@ -133,6 +140,41 @@ interface TmdbApi {
@Query("api_key") apiKey: String,
@Query("language") language: String? = null
): Response<TmdbPersonCreditsResponse>
@GET("company/{company_id}")
suspend fun getCompanyDetails(
@Path("company_id") companyId: Int,
@Query("api_key") apiKey: String
): Response<TmdbCompanyDetailsResponse>
@GET("network/{network_id}")
suspend fun getNetworkDetails(
@Path("network_id") networkId: Int,
@Query("api_key") apiKey: String
): Response<TmdbNetworkDetailsResponse>
@GET("discover/movie")
suspend fun discoverMovies(
@Query("api_key") apiKey: String,
@Query("language") language: String? = null,
@Query("page") page: Int = 1,
@Query("sort_by") sortBy: String? = null,
@Query("with_companies") withCompanies: String? = null,
@Query("release_date.lte") releaseDateLte: String? = null,
@Query("vote_count.gte") voteCountGte: Int? = null
): Response<TmdbDiscoverResponse>
@GET("discover/tv")
suspend fun discoverTv(
@Query("api_key") apiKey: String,
@Query("language") language: String? = null,
@Query("page") page: Int = 1,
@Query("sort_by") sortBy: String? = null,
@Query("with_companies") withCompanies: String? = null,
@Query("with_networks") withNetworks: String? = null,
@Query("first_air_date.lte") firstAirDateLte: String? = null,
@Query("vote_count.gte") voteCountGte: Int? = null
): Response<TmdbDiscoverResponse>
}
@JsonClass(generateAdapter = true)
@ -197,7 +239,10 @@ data class TmdbDetailsResponse(
@Json(name = "origin_country") val originCountry: List<String>? = null,
@Json(name = "original_language") val originalLanguage: String? = null,
@Json(name = "backdrop_path") val backdropPath: String? = null,
@Json(name = "poster_path") val posterPath: String? = null
@Json(name = "poster_path") val posterPath: String? = null,
@Json(name = "last_air_date") val lastAirDate: String? = null,
@Json(name = "status") val status: String? = null,
@Json(name = "belongs_to_collection") val belongsToCollection: TmdbCollectionSummary? = null
)
@JsonClass(generateAdapter = true)
@ -215,12 +260,14 @@ data class TmdbGenre(
@JsonClass(generateAdapter = true)
data class TmdbCompany(
@Json(name = "id") val id: Int? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "logo_path") val logoPath: String? = null
)
@JsonClass(generateAdapter = true)
data class TmdbNetwork(
@Json(name = "id") val id: Int? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "logo_path") val logoPath: String? = null
)
@ -284,7 +331,8 @@ data class TmdbTvContentRatingItem(
@JsonClass(generateAdapter = true)
data class TmdbImage(
@Json(name = "file_path") val filePath: String? = null,
@Json(name = "iso_639_1") val iso6391: String? = null
@Json(name = "iso_639_1") val iso6391: String? = null,
@Json(name = "iso_3166_1") val iso31661: String? = null
)
@JsonClass(generateAdapter = true)
@ -314,6 +362,31 @@ data class TmdbRecommendationsResponse(
@Json(name = "results") val results: List<TmdbRecommendationResult>? = null
)
@JsonClass(generateAdapter = true)
data class TmdbDiscoverResponse(
@Json(name = "page") val page: Int? = null,
@Json(name = "results") val results: List<TmdbDiscoverResult>? = null,
@Json(name = "total_pages") val totalPages: Int? = null,
@Json(name = "total_results") val totalResults: Int? = null
)
@JsonClass(generateAdapter = true)
data class TmdbDiscoverResult(
@Json(name = "id") val id: Int,
@Json(name = "title") val title: String? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "original_title") val originalTitle: String? = null,
@Json(name = "original_name") val originalName: String? = null,
@Json(name = "poster_path") val posterPath: String? = null,
@Json(name = "backdrop_path") val backdropPath: String? = null,
@Json(name = "overview") val overview: String? = null,
@Json(name = "release_date") val releaseDate: String? = null,
@Json(name = "first_air_date") val firstAirDate: String? = null,
@Json(name = "vote_average") val voteAverage: Double? = null,
@Json(name = "vote_count") val voteCount: Int? = null,
@Json(name = "popularity") val popularity: Double? = null
)
@JsonClass(generateAdapter = true)
data class TmdbRecommendationResult(
@Json(name = "id") val id: Int,
@ -385,3 +458,53 @@ data class TmdbPersonCreditCrew(
@Json(name = "overview") val overview: String? = null,
@Json(name = "genre_ids") val genreIds: List<Int>? = null
)
@JsonClass(generateAdapter = true)
data class TmdbCollectionResponse(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String? = null,
@Json(name = "overview") val overview: String? = null,
@Json(name = "poster_path") val posterPath: String? = null,
@Json(name = "backdrop_path") val backdropPath: String? = null,
@Json(name = "parts") val parts: List<TmdbCollectionPart>? = null
)
@JsonClass(generateAdapter = true)
data class TmdbCollectionPart(
@Json(name = "id") val id: Int,
@Json(name = "title") val title: String? = null,
@Json(name = "overview") val overview: String? = null,
@Json(name = "release_date") val releaseDate: String? = null,
@Json(name = "poster_path") val posterPath: String? = null,
@Json(name = "backdrop_path") val backdropPath: String? = null,
@Json(name = "vote_average") val voteAverage: Double? = null
)
@JsonClass(generateAdapter = true)
data class TmdbCollectionSummary(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String? = null,
@Json(name = "poster_path") val posterPath: String? = null,
@Json(name = "backdrop_path") val backdropPath: String? = null
)
@JsonClass(generateAdapter = true)
data class TmdbCompanyDetailsResponse(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String? = null,
@Json(name = "description") val description: String? = null,
@Json(name = "headquarters") val headquarters: String? = null,
@Json(name = "homepage") val homepage: String? = null,
@Json(name = "logo_path") val logoPath: String? = null,
@Json(name = "origin_country") val originCountry: String? = null
)
@JsonClass(generateAdapter = true)
data class TmdbNetworkDetailsResponse(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String? = null,
@Json(name = "headquarters") val headquarters: String? = null,
@Json(name = "homepage") val homepage: String? = null,
@Json(name = "logo_path") val logoPath: String? = null,
@Json(name = "origin_country") val originCountry: String? = null
)

View file

@ -9,11 +9,13 @@ import com.nuvio.tv.data.remote.dto.trakt.TraktHistoryRemoveResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktHistoryAddRequestDto
import com.nuvio.tv.data.remote.dto.trakt.TraktHistoryAddResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktHistoryItemDto
import com.nuvio.tv.data.remote.dto.trakt.TraktCommentDto
import com.nuvio.tv.data.remote.dto.trakt.TraktCreateOrUpdateListRequestDto
import com.nuvio.tv.data.remote.dto.trakt.TraktListItemDto
import com.nuvio.tv.data.remote.dto.trakt.TraktListItemsMutationRequestDto
import com.nuvio.tv.data.remote.dto.trakt.TraktListItemsMutationResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktListSummaryDto
import com.nuvio.tv.data.remote.dto.trakt.TraktMovieDto
import com.nuvio.tv.data.remote.dto.trakt.TraktPlaybackItemDto
import com.nuvio.tv.data.remote.dto.trakt.TraktReorderListsRequestDto
import com.nuvio.tv.data.remote.dto.trakt.TraktReorderListsResponseDto
@ -21,12 +23,16 @@ import com.nuvio.tv.data.remote.dto.trakt.TraktRefreshTokenRequestDto
import com.nuvio.tv.data.remote.dto.trakt.TraktRevokeRequestDto
import com.nuvio.tv.data.remote.dto.trakt.TraktScrobbleRequestDto
import com.nuvio.tv.data.remote.dto.trakt.TraktScrobbleResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktSearchResultDto
import com.nuvio.tv.data.remote.dto.trakt.TraktSeasonDto
import com.nuvio.tv.data.remote.dto.trakt.TraktShowDto
import com.nuvio.tv.data.remote.dto.trakt.TraktShowProgressResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktTokenResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktUserEpisodeHistoryItemDto
import com.nuvio.tv.data.remote.dto.trakt.TraktUserSettingsResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktUserStatsResponseDto
import com.nuvio.tv.data.remote.dto.trakt.TraktWatchedMovieItemDto
import com.nuvio.tv.data.remote.dto.trakt.TraktWatchedShowItemDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
@ -103,6 +109,12 @@ interface TraktApi {
@Query("extended") extended: String? = null
): Response<List<TraktWatchedMovieItemDto>>
@GET("sync/watched/shows")
suspend fun getWatchedShows(
@Header("Authorization") authorization: String,
@Query("extended") extended: String? = null
): Response<List<TraktWatchedShowItemDto>>
@GET("sync/history/episodes")
suspend fun getEpisodeHistory(
@Header("Authorization") authorization: String,
@ -134,6 +146,57 @@ interface TraktApi {
@Query("count_specials") countSpecials: Boolean = false
): Response<TraktShowProgressResponseDto>
@GET("shows/{id}/seasons")
suspend fun getShowSeasons(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Query("extended") extended: String? = null
): Response<List<TraktSeasonDto>>
@GET("movies/{id}/comments/{sort}")
suspend fun getMovieComments(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Path("sort") sort: String = "likes",
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 10
): Response<List<TraktCommentDto>>
@GET("shows/{id}/comments/{sort}")
suspend fun getShowComments(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Path("sort") sort: String = "likes",
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 10
): Response<List<TraktCommentDto>>
@GET("movies/{id}/related")
suspend fun getMovieRelated(
@Header("Authorization") authorization: String? = null,
@Path("id") id: String,
@Query("extended") extended: String = "full,images",
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): Response<List<TraktMovieDto>>
@GET("shows/{id}/related")
suspend fun getShowRelated(
@Header("Authorization") authorization: String? = null,
@Path("id") id: String,
@Query("extended") extended: String = "full,images",
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): Response<List<TraktShowDto>>
@GET("search/{id_type}/{id}")
suspend fun searchById(
@Header("Authorization") authorization: String,
@Path("id_type") idType: String,
@Path("id") id: String,
@Query("type") type: String
): Response<List<TraktSearchResultDto>>
@DELETE("sync/playback/{id}")
suspend fun deletePlayback(
@Header("Authorization") authorization: String,

View file

@ -13,7 +13,13 @@ data class AddonManifestDto(
@Json(name = "background") val background: String? = null,
@Json(name = "catalogs") val catalogs: List<CatalogDescriptorDto> = emptyList(),
@Json(name = "resources") val resources: List<Any> = emptyList(),
@Json(name = "types") val types: List<String> = emptyList()
@Json(name = "types") val types: List<String> = emptyList(),
@Json(name = "idPrefixes") val idPrefixes: List<String> = emptyList(),
@Json(name = "behaviorHints") val behaviorHints: AddonBehaviorHintsDto? = null,
@Json(name = "stremioAddonsConfig") val stremioAddonsConfig: StremioAddonsConfigDto? = null,
@Json(name = "language") val language: String? = null,
@Json(name = "configVersion") val configVersion: Long? = null,
@Json(name = "_timestamp") val timestamp: Long? = null
)
@JsonClass(generateAdapter = true)
@ -21,12 +27,22 @@ data class CatalogDescriptorDto(
@Json(name = "type") val type: String,
@Json(name = "id") val id: String,
@Json(name = "name") val name: String,
@Json(name = "extra") val extra: List<ExtraDto>? = null
@Json(name = "extra") val extra: List<Any>? = null,
@Json(name = "pageSize") val pageSize: Int? = null,
@Json(name = "showInHome") val showInHome: Boolean? = null,
@Json(name = "extraSupported") val extraSupported: List<String>? = null,
@Json(name = "extraRequired") val extraRequired: List<String>? = null
)
@JsonClass(generateAdapter = true)
data class ExtraDto(
@Json(name = "name") val name: String,
@Json(name = "isRequired") val isRequired: Boolean? = false,
@Json(name = "options") val options: List<String>? = null
data class AddonBehaviorHintsDto(
@Json(name = "configurable") val configurable: Boolean? = null,
@Json(name = "configurationRequired") val configurationRequired: Boolean? = null,
@Json(name = "newEpisodeNotifications") val newEpisodeNotifications: Boolean? = null
)
@JsonClass(generateAdapter = true)
data class StremioAddonsConfigDto(
@Json(name = "issuer") val issuer: String? = null,
@Json(name = "signature") val signature: String? = null
)

View file

@ -21,5 +21,19 @@ data class MetaPreviewDto(
@Json(name = "releaseInfo") val releaseInfo: String? = null,
@Json(name = "imdbRating") val imdbRating: String? = null,
@Json(name = "genres") val genres: List<String>? = null,
@Json(name = "runtime") val runtime: String? = null
@Json(name = "runtime") val runtime: String? = null,
@Json(name = "status") val status: String? = null,
@Json(name = "released") val released: String? = null,
@Json(name = "country") val country: String? = null,
@Json(name = "imdb_id") val imdbId: String? = null,
@Json(name = "slug") val slug: String? = null,
@Json(name = "landscapePoster") val landscapePoster: String? = null,
@Json(name = "_rawPosterUrl") val rawPosterUrl: String? = null,
@Json(name = "director") val director: Any? = null,
@Json(name = "writer") val writer: Any? = null,
@Json(name = "writers") val writers: Any? = null,
@Json(name = "links") val links: List<MetaLinkDto>? = null,
@Json(name = "trailers") val trailers: List<MetaTrailerDto>? = null,
@Json(name = "behaviorHints") val behaviorHints: MetaBehaviorHintsDto? = null,
@Json(name = "trailerStreams") val trailerStreams: List<TrailerStreamDto>? = null
)

View file

@ -0,0 +1,17 @@
package com.nuvio.tv.data.remote.dto
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DonationsResponseDto(
val donations: List<DonationDto> = emptyList()
)
@JsonClass(generateAdapter = true)
data class DonationDto(
val name: String? = null,
val amount: Double? = null,
val currency: String? = null,
val date: String? = null,
val message: String? = null
)

View file

@ -0,0 +1,13 @@
package com.nuvio.tv.data.remote.dto
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GitHubContributorDto(
val login: String? = null,
@param:Json(name = "avatar_url") val avatarUrl: String? = null,
@param:Json(name = "html_url") val htmlUrl: String? = null,
val contributions: Int? = null,
val type: String? = null
)

View file

@ -17,9 +17,14 @@ data class MetaDto(
@Json(name = "posterShape") val posterShape: String? = null,
@Json(name = "background") val background: String? = null,
@Json(name = "logo") val logo: String? = null,
@Json(name = "landscapePoster") val landscapePoster: String? = null,
@Json(name = "description") val description: String? = null,
@Json(name = "releaseInfo") val releaseInfo: String? = null,
@Json(name = "released") val released: String? = null,
@Json(name = "status") val status: String? = null,
@Json(name = "imdbRating") val imdbRating: String? = null,
@Json(name = "imdb_id") val imdbId: String? = null,
@Json(name = "slug") val slug: String? = null,
@Json(name = "genres") val genres: List<String>? = null,
@Json(name = "runtime") val runtime: String? = null,
// Stremio addons are inconsistent here (string vs list). Keep it tolerant.
@ -33,19 +38,39 @@ data class MetaDto(
@Json(name = "awards") val awards: String? = null,
@Json(name = "language") val language: String? = null,
@Json(name = "links") val links: List<MetaLinkDto>? = null,
@Json(name = "app_extras") val appExtras: AppExtrasDto? = null
@Json(name = "trailers") val trailers: List<MetaTrailerDto>? = null,
@Json(name = "behaviorHints") val behaviorHints: MetaBehaviorHintsDto? = null,
@Json(name = "trailerStreams") val trailerStreams: List<TrailerStreamDto>? = null,
@Json(name = "app_extras") val appExtras: AppExtrasDto? = null,
@Json(name = "_rawPosterUrl") val rawPosterUrl: String? = null,
@Json(name = "_hasPoster") val hasPoster: Boolean? = null,
@Json(name = "_hasBackground") val hasBackground: Boolean? = null,
@Json(name = "_hasLandscapePoster") val hasLandscapePoster: Boolean? = null,
@Json(name = "_hasLogo") val hasLogo: Boolean? = null,
@Json(name = "_hasLinks") val hasLinks: Boolean? = null,
@Json(name = "_hasVideos") val hasVideos: Boolean? = null
)
@JsonClass(generateAdapter = true)
data class TrailerStreamDto(
@Json(name = "ytId") val ytId: String? = null
)
@JsonClass(generateAdapter = true)
data class AppExtrasDto(
@Json(name = "cast") val cast: List<AppExtrasCastMemberDto>? = null
@Json(name = "cast") val cast: List<AppExtrasCastMemberDto>? = null,
@Json(name = "directors") val directors: List<AppExtrasCastMemberDto>? = null,
@Json(name = "writers") val writers: List<AppExtrasCastMemberDto>? = null,
@Json(name = "releaseDates") val releaseDates: MetaReleaseDatesEnvelopeDto? = null,
@Json(name = "certification") val certification: String? = null
)
@JsonClass(generateAdapter = true)
data class AppExtrasCastMemberDto(
@Json(name = "name") val name: String,
@Json(name = "character") val character: String? = null,
@Json(name = "photo") val photo: String? = null
@Json(name = "photo") val photo: String? = null,
@Json(name = "tmdbId") val tmdbId: Int? = null
)
@JsonClass(generateAdapter = true)
@ -60,7 +85,9 @@ data class VideoDto(
@Json(name = "episode") val episode: Int? = null,
@Json(name = "number") val number: Int? = null,
@Json(name = "overview") val overview: String? = null,
@Json(name = "description") val description: String? = null
@Json(name = "description") val description: String? = null,
@Json(name = "runtime") val runtime: String? = null,
@Json(name = "available") val available: Boolean? = null
)
@JsonClass(generateAdapter = true)
@ -69,3 +96,39 @@ data class MetaLinkDto(
@Json(name = "category") val category: String,
@Json(name = "url") val url: String? = null
)
@JsonClass(generateAdapter = true)
data class MetaTrailerDto(
@Json(name = "source") val source: String? = null,
@Json(name = "type") val type: String? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "ytId") val ytId: String? = null,
@Json(name = "lang") val lang: String? = null
)
@JsonClass(generateAdapter = true)
data class MetaBehaviorHintsDto(
@Json(name = "defaultVideoId") val defaultVideoId: String? = null,
@Json(name = "hasScheduledVideos") val hasScheduledVideos: Boolean? = null
)
@JsonClass(generateAdapter = true)
data class MetaReleaseDatesEnvelopeDto(
@Json(name = "results") val results: List<MetaReleaseDateCountryDto>? = null
)
@JsonClass(generateAdapter = true)
data class MetaReleaseDateCountryDto(
@Json(name = "iso_3166_1") val iso31661: String,
@Json(name = "release_dates") val releaseDates: List<MetaReleaseDateDto>? = null
)
@JsonClass(generateAdapter = true)
data class MetaReleaseDateDto(
@Json(name = "certification") val certification: String? = null,
@Json(name = "descriptors") val descriptors: List<String>? = null,
@Json(name = "iso_639_1") val iso6391: String? = null,
@Json(name = "note") val note: String? = null,
@Json(name = "release_date") val releaseDate: String? = null,
@Json(name = "type") val type: Int? = null
)

View file

@ -0,0 +1,42 @@
package com.nuvio.tv.data.remote.dto.trakt
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TraktCommentDto(
@Json(name = "id") val id: Long,
@Json(name = "created_at") val createdAt: String? = null,
@Json(name = "updated_at") val updatedAt: String? = null,
@Json(name = "comment") val comment: String? = null,
@Json(name = "spoiler") val spoiler: Boolean? = null,
@Json(name = "review") val review: Boolean? = null,
@Json(name = "likes") val likes: Int? = null,
@Json(name = "user_stats") val userStats: TraktCommentUserStatsDto? = null,
@Json(name = "user") val user: TraktCommentUserDto? = null
)
@JsonClass(generateAdapter = true)
data class TraktCommentUserStatsDto(
@Json(name = "rating") val rating: Int? = null,
@Json(name = "play_count") val playCount: Int? = null,
@Json(name = "completed_count") val completedCount: Int? = null
)
@JsonClass(generateAdapter = true)
data class TraktCommentUserDto(
@Json(name = "username") val username: String? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "private") val isPrivate: Boolean? = null,
@Json(name = "vip") val vip: Boolean? = null,
@Json(name = "vip_ep") val vipEp: Boolean? = null,
@Json(name = "ids") val ids: TraktIdsDto? = null
)
@JsonClass(generateAdapter = true)
data class TraktSearchResultDto(
@Json(name = "type") val type: String? = null,
@Json(name = "score") val score: Double? = null,
@Json(name = "movie") val movie: TraktMovieDto? = null,
@Json(name = "show") val show: TraktShowDto? = null
)

View file

@ -16,14 +16,56 @@ data class TraktIdsDto(
data class TraktMovieDto(
@Json(name = "title") val title: String? = null,
@Json(name = "year") val year: Int? = null,
@Json(name = "ids") val ids: TraktIdsDto? = null
@Json(name = "ids") val ids: TraktIdsDto? = null,
@Json(name = "tagline") val tagline: String? = null,
@Json(name = "overview") val overview: String? = null,
@Json(name = "released") val released: String? = null,
@Json(name = "runtime") val runtime: Int? = null,
@Json(name = "country") val country: String? = null,
@Json(name = "status") val status: String? = null,
@Json(name = "rating") val rating: Double? = null,
@Json(name = "genres") val genres: List<String>? = null,
@Json(name = "certification") val certification: String? = null,
@Json(name = "languages") val languages: List<String>? = null,
@Json(name = "original_title") val originalTitle: String? = null,
@Json(name = "images") val images: TraktImagesDto? = null
)
@JsonClass(generateAdapter = true)
data class TraktShowDto(
@Json(name = "title") val title: String? = null,
@Json(name = "year") val year: Int? = null,
@Json(name = "ids") val ids: TraktIdsDto? = null
@Json(name = "ids") val ids: TraktIdsDto? = null,
@Json(name = "tagline") val tagline: String? = null,
@Json(name = "overview") val overview: String? = null,
@Json(name = "first_aired") val firstAired: String? = null,
@Json(name = "runtime") val runtime: Int? = null,
@Json(name = "network") val network: String? = null,
@Json(name = "country") val country: String? = null,
@Json(name = "status") val status: String? = null,
@Json(name = "rating") val rating: Double? = null,
@Json(name = "genres") val genres: List<String>? = null,
@Json(name = "certification") val certification: String? = null,
@Json(name = "languages") val languages: List<String>? = null,
@Json(name = "original_title") val originalTitle: String? = null,
@Json(name = "images") val images: TraktImagesDto? = null
)
@JsonClass(generateAdapter = true)
data class TraktImagesDto(
@Json(name = "fanart") val fanart: List<String>? = null,
@Json(name = "poster") val poster: List<String>? = null,
@Json(name = "logo") val logo: List<String>? = null,
@Json(name = "clearart") val clearart: List<String>? = null,
@Json(name = "banner") val banner: List<String>? = null,
@Json(name = "thumb") val thumb: List<String>? = null
)
@JsonClass(generateAdapter = true)
data class TraktSeasonDto(
@Json(name = "number") val number: Int? = null,
@Json(name = "ids") val ids: TraktIdsDto? = null,
@Json(name = "episodes") val episodes: List<TraktEpisodeDto>? = null
)
@JsonClass(generateAdapter = true)
@ -33,4 +75,3 @@ data class TraktEpisodeDto(
@Json(name = "number") val number: Int? = null,
@Json(name = "ids") val ids: TraktIdsDto? = null
)

View file

@ -35,6 +35,28 @@ data class TraktWatchedMovieItemDto(
@Json(name = "movie") val movie: TraktMovieDto? = null
)
@JsonClass(generateAdapter = true)
data class TraktWatchedShowItemDto(
@Json(name = "plays") val plays: Int? = null,
@Json(name = "last_watched_at") val lastWatchedAt: String? = null,
@Json(name = "last_updated_at") val lastUpdatedAt: String? = null,
@Json(name = "show") val show: TraktShowDto? = null,
@Json(name = "seasons") val seasons: List<TraktWatchedShowSeasonDto>? = null
)
@JsonClass(generateAdapter = true)
data class TraktWatchedShowSeasonDto(
@Json(name = "number") val number: Int? = null,
@Json(name = "episodes") val episodes: List<TraktWatchedShowEpisodeDto>? = null
)
@JsonClass(generateAdapter = true)
data class TraktWatchedShowEpisodeDto(
@Json(name = "number") val number: Int? = null,
@Json(name = "plays") val plays: Int? = null,
@Json(name = "last_watched_at") val lastWatchedAt: String? = null
)
@JsonClass(generateAdapter = true)
data class TraktUserEpisodeHistoryItemDto(
@Json(name = "id") val id: Long? = null,

View file

@ -0,0 +1,56 @@
package com.nuvio.tv.data.remote.supabase
import com.nuvio.tv.BuildConfig
import io.github.jan.supabase.postgrest.Postgrest
import javax.inject.Inject
import javax.inject.Singleton
data class AvatarCatalogItem(
val id: String,
val displayName: String,
val imageUrl: String,
val category: String,
val sortOrder: Int,
val bgColor: String? = null
)
@Singleton
class AvatarRepository @Inject constructor(
private val postgrest: Postgrest
) {
private var cachedCatalog: List<AvatarCatalogItem>? = null
suspend fun getAvatarCatalog(): List<AvatarCatalogItem> {
cachedCatalog?.let { return it }
val response = postgrest.rpc("get_avatar_catalog")
val remote = response.decodeList<SupabaseAvatarCatalogItem>()
val catalog = remote.map { item ->
AvatarCatalogItem(
id = item.id,
displayName = item.displayName,
imageUrl = avatarImageUrl(item.storagePath),
category = item.category,
sortOrder = item.sortOrder,
bgColor = item.bgColor
)
}
cachedCatalog = catalog
return catalog
}
fun getAvatarImageUrl(avatarId: String, catalog: List<AvatarCatalogItem>): String? {
return catalog.find { it.id == avatarId }?.imageUrl
}
fun invalidateCache() {
cachedCatalog = null
}
companion object {
fun avatarImageUrl(storagePath: String): String {
val baseUrl = BuildConfig.AVATAR_PUBLIC_BASE_URL.trimEnd('/')
return if (baseUrl.isNotEmpty()) "$baseUrl/$storagePath" else storagePath
}
}
}

View file

@ -2,6 +2,8 @@ package com.nuvio.tv.data.remote.supabase
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
@Serializable
data class SupabasePlugin(
@ -130,6 +132,37 @@ data class SupabaseProfile(
@SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5",
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
@SerialName("avatar_id") val avatarId: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null
)
@Serializable
data class SupabaseProfileLockState(
@SerialName("profile_index") val profileIndex: Int,
@SerialName("pin_enabled") val pinEnabled: Boolean = false,
@SerialName("pin_locked_until") val pinLockedUntil: String? = null
)
@Serializable
data class SupabaseProfilePinVerifyResult(
val unlocked: Boolean = false,
@SerialName("retry_after_seconds") val retryAfterSeconds: Int = 0
)
@Serializable
data class SupabaseAvatarCatalogItem(
val id: String,
@SerialName("display_name") val displayName: String,
@SerialName("storage_path") val storagePath: String,
val category: String,
@SerialName("sort_order") val sortOrder: Int = 0,
@SerialName("bg_color") val bgColor: String? = null
)
@Serializable
data class SupabaseProfileSettingsBlob(
@SerialName("profile_id") val profileId: Int = 1,
@SerialName("settings_json") val settingsJson: JsonObject = buildJsonObject { },
@SerialName("updated_at") val updatedAt: String? = null
)

View file

@ -41,8 +41,10 @@ class AddonRepositoryImpl @Inject constructor(
companion object {
private const val TAG = "AddonRepository"
private const val MANIFEST_CACHE_PREFS = "addon_manifest_cache"
private const val MANIFEST_CACHE_KEY = "manifests"
private const val MANIFEST_CACHE_KEY = "manifests_v2"
private const val LEGACY_MANIFEST_CACHE_KEY = "manifests"
private const val MANIFEST_SUFFIX = "/manifest.json"
private const val MANIFEST_CACHE_TTL_MS = 6 * 60 * 60 * 1000L
}
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -80,17 +82,42 @@ class AddonRepositoryImpl @Inject constructor(
private val gson = Gson()
private val manifestCache = mutableMapOf<String, Addon>()
@Volatile
private var lastManifestRefreshTime = 0L
private var manifestRefreshJob: Job? = null
init {
loadManifestCacheFromDisk()
syncScope.launch { loadManifestCacheFromDisk() }
}
private fun loadManifestCacheFromDisk() {
private fun isCacheStale(): Boolean =
System.currentTimeMillis() - lastManifestRefreshTime > MANIFEST_CACHE_TTL_MS
private fun scheduleManifestRefresh(urls: List<String>) {
if (manifestRefreshJob?.isActive == true) return
manifestRefreshJob = syncScope.launch {
val refreshed = urls.map { url ->
async {
fetchAddon(url)
}
}.awaitAll()
val anyUpdated = refreshed.any { it is NetworkResult.Success }
if (anyUpdated) {
lastManifestRefreshTime = System.currentTimeMillis()
Log.d(TAG, "Background manifest refresh completed")
}
}
}
private suspend fun loadManifestCacheFromDisk() = kotlinx.coroutines.withContext(Dispatchers.IO) {
try {
val prefs = context.getSharedPreferences(MANIFEST_CACHE_PREFS, Context.MODE_PRIVATE)
val json = prefs.getString(MANIFEST_CACHE_KEY, null) ?: return
if (prefs.contains(LEGACY_MANIFEST_CACHE_KEY)) {
prefs.edit().remove(LEGACY_MANIFEST_CACHE_KEY).apply()
}
val json = prefs.getString(MANIFEST_CACHE_KEY, null) ?: return@withContext
val type = object : TypeToken<Map<String, Addon>>() {}.type
val cached: Map<String, Addon> = gson.fromJson(json, type) ?: return
val cached: Map<String, Addon> = gson.fromJson(json, type) ?: return@withContext
manifestCache.putAll(cached)
Log.d(TAG, "Loaded ${cached.size} cached manifests from disk")
} catch (e: Exception) {
@ -99,37 +126,44 @@ class AddonRepositoryImpl @Inject constructor(
}
private fun persistManifestCacheToDisk() {
try {
val prefs = context.getSharedPreferences(MANIFEST_CACHE_PREFS, Context.MODE_PRIVATE)
prefs.edit().putString(MANIFEST_CACHE_KEY, gson.toJson(manifestCache.toMap())).apply()
} catch (e: Exception) {
Log.w(TAG, "Failed to persist manifest cache to disk", e)
syncScope.launch {
try {
val snapshot = manifestCache.toMap()
val prefs = context.getSharedPreferences(MANIFEST_CACHE_PREFS, Context.MODE_PRIVATE)
prefs.edit().putString(MANIFEST_CACHE_KEY, gson.toJson(snapshot)).apply()
} catch (e: Exception) {
Log.w(TAG, "Failed to persist manifest cache to disk", e)
}
}
}
override fun getInstalledAddons(): Flow<List<Addon>> =
preferences.installedAddonUrls.flatMapLatest { urls ->
flow {
// Emit cached addons immediately (now includes disk-persisted cache)
val cached = urls.mapNotNull { manifestCache[canonicalizeUrl(it)] }
if (cached.isNotEmpty()) {
emit(applyDisplayNames(cached))
}
val fresh = coroutineScope {
urls.map { url ->
async {
when (val result = fetchAddon(url)) {
is NetworkResult.Success -> result.data
else -> manifestCache[canonicalizeUrl(url)]
val hasCacheMiss = cached.size < urls.size
if (hasCacheMiss) {
val fresh = coroutineScope {
urls.map { url ->
async {
val canonical = canonicalizeUrl(url)
manifestCache[canonical] ?: when (val result = fetchAddon(url)) {
is NetworkResult.Success -> result.data
else -> null
}
}
}
}.awaitAll().filterNotNull()
}
}.awaitAll().filterNotNull()
}
if (fresh != cached || cached.isEmpty()) {
emit(applyDisplayNames(fresh))
if (fresh != cached) {
emit(applyDisplayNames(fresh))
}
} else if (isCacheStale() && urls.isNotEmpty()) {
scheduleManifestRefresh(urls)
}
}.flowOn(Dispatchers.IO)
}
@ -146,7 +180,7 @@ class AddonRepositoryImpl @Inject constructor(
NetworkResult.Success(addon)
}
is NetworkResult.Error -> {
Log.w(TAG, "Failed to fetch addon manifest for url=$cleanBaseUrl code=${result.code} message=${result.message}")
Log.w(TAG, "Failed to fetch addon manifest for url=$manifestUrl code=${result.code} message=${result.message}")
result
}
NetworkResult.Loading -> NetworkResult.Loading
@ -193,30 +227,35 @@ class AddonRepositoryImpl @Inject constructor(
removeMissingLocal
}
val localByNormalized = linkedMapOf<String, String>()
initialLocalUrls.forEach { url ->
localByNormalized.putIfAbsent(normalizeUrl(url), canonicalizeUrl(url))
}
val remoteOrdered = normalizedRemote.map { remote ->
localByNormalized[normalizeUrl(remote)] ?: remote
}
val finalList = if (shouldRemoveMissingLocal) {
remoteOrdered
} else {
val extras = initialLocalUrls
.map { canonicalizeUrl(it) }
.filter { normalizeUrl(it) !in remoteSet }
remoteOrdered + extras
}
if (shouldRemoveMissingLocal) {
initialLocalUrls
.filter { normalizeUrl(it) !in remoteSet }
.forEach { removeAddon(it) }
.forEach { manifestCache.remove(canonicalizeUrl(it)) }
}
normalizedRemote
.filter { normalizeUrl(it) !in initialLocalSet }
.forEach { addAddon(it) }
val currentUrls = preferences.installedAddonUrls.first()
val currentByNormalizedUrl = linkedMapOf<String, String>()
currentUrls.forEach { url ->
currentByNormalizedUrl.putIfAbsent(normalizeUrl(url), canonicalizeUrl(url))
}
val remoteOrdered = normalizedRemote
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it)] }
val extras = currentUrls
.map { canonicalizeUrl(it) }
.filter { normalizeUrl(it) !in remoteSet }
val reordered = if (shouldRemoveMissingLocal) remoteOrdered else remoteOrdered + extras
if (reordered != currentUrls.map { canonicalizeUrl(it) }) {
preferences.setAddonOrder(reordered)
val currentCanonical = initialLocalUrls.map { canonicalizeUrl(it) }
if (finalList != currentCanonical) {
preferences.setAddonOrder(finalList)
}
}

View file

@ -33,6 +33,7 @@ class CatalogRepositoryImpl @Inject constructor(
catalogName: String,
type: String,
skip: Int,
skipStep: Int,
extraArgs: Map<String, String>,
supportsSkip: Boolean
): Flow<NetworkResult<CatalogRow>> = flow {
@ -42,6 +43,7 @@ class CatalogRepositoryImpl @Inject constructor(
type = type,
catalogId = catalogId,
skip = skip,
skipStep = skipStep,
extraArgs = extraArgs
)
@ -56,17 +58,22 @@ class CatalogRepositoryImpl @Inject constructor(
val url = buildCatalogUrl(addonBaseUrl, type, catalogId, skip, extraArgs)
Log.d(
TAG,
"Fetching catalog addonId=$addonId addonName=$addonName type=$type catalogId=$catalogId skip=$skip supportsSkip=$supportsSkip url=$url"
"Fetching catalog addonId=$addonId addonName=$addonName type=$type catalogId=$catalogId skip=$skip skipStep=$skipStep supportsSkip=$supportsSkip url=$url"
)
when (val result = safeApiCall { api.getCatalog(url) }) {
is NetworkResult.Success -> {
val items = result.data.metas.map { it.toDomain() }
val items = result.data.metas.map { it.toDomain() }.distinctBy { it.id }
Log.d(
TAG,
"Catalog fetch success addonId=$addonId type=$type catalogId=$catalogId items=${items.size}"
)
val effectiveSkipStep = if (skip == 0 && items.isNotEmpty() && items.size < skipStep) {
items.size
} else {
skipStep
}
val catalogRow = CatalogRow(
addonId = addonId,
addonName = addonName,
@ -78,8 +85,9 @@ class CatalogRepositoryImpl @Inject constructor(
items = items,
isLoading = false,
hasMore = supportsSkip && items.isNotEmpty(),
currentPage = skip / 100,
supportsSkip = supportsSkip
currentPage = if (effectiveSkipStep > 0) skip / effectiveSkipStep else 0,
supportsSkip = supportsSkip,
skipStep = effectiveSkipStep
)
catalogCache[cacheKey] = catalogRow
// Only emit fresh data if it differs from cache
@ -143,6 +151,7 @@ class CatalogRepositoryImpl @Inject constructor(
type: String,
catalogId: String,
skip: Int,
skipStep: Int,
extraArgs: Map<String, String>
): String {
val normalizedArgs = extraArgs.entries

View file

@ -0,0 +1,138 @@
package com.nuvio.tv.data.repository
import com.nuvio.tv.data.remote.api.GitHubReleaseApi
import com.nuvio.tv.data.remote.dto.GitHubContributorDto
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import javax.inject.Inject
import javax.inject.Singleton
data class GitHubContributor(
val login: String,
val avatarUrl: String?,
val profileUrl: String?,
val totalContributions: Int,
val tvContributions: Int,
val mobileContributions: Int
)
@Singleton
class GitHubContributorsRepository @Inject constructor(
private val gitHubApi: GitHubReleaseApi
) {
suspend fun getContributors(): Result<List<GitHubContributor>> = runCatching {
coroutineScope {
val tvDeferred = async { fetchRepoContributors(repo = TV_REPOSITORY) }
val mobileDeferred = async { fetchRepoContributors(repo = MOBILE_REPOSITORY) }
val tvResult = tvDeferred.await()
val mobileResult = mobileDeferred.await()
if (tvResult.isFailure && mobileResult.isFailure) {
throw (tvResult.exceptionOrNull() ?: mobileResult.exceptionOrNull() ?: IllegalStateException("Unable to load contributors"))
}
mergeContributors(
tvContributors = tvResult.getOrDefault(emptyList()),
mobileContributors = mobileResult.getOrDefault(emptyList())
)
}
}
private suspend fun fetchRepoContributors(repo: String): Result<List<GitHubContributorDto>> = runCatching {
val response = gitHubApi.getContributors(owner = OWNER, repo = repo)
if (!response.isSuccessful) {
error("GitHub contributors API error for $repo: ${response.code()}")
}
response.body().orEmpty()
}
private fun mergeContributors(
tvContributors: List<GitHubContributorDto>,
mobileContributors: List<GitHubContributorDto>
): List<GitHubContributor> {
val contributorsByLogin = linkedMapOf<String, MutableGitHubContributor>()
tvContributors.forEach { dto ->
val normalized = dto.toNormalizedContributor() ?: return@forEach
val existing = contributorsByLogin.getOrPut(normalized.login) {
MutableGitHubContributor(
login = normalized.login,
avatarUrl = normalized.avatarUrl,
profileUrl = normalized.htmlUrl
)
}
existing.avatarUrl = existing.avatarUrl ?: normalized.avatarUrl
existing.profileUrl = existing.profileUrl ?: normalized.htmlUrl
existing.tvContributions += normalized.contributions
}
mobileContributors.forEach { dto ->
val normalized = dto.toNormalizedContributor() ?: return@forEach
val existing = contributorsByLogin.getOrPut(normalized.login) {
MutableGitHubContributor(
login = normalized.login,
avatarUrl = normalized.avatarUrl,
profileUrl = normalized.htmlUrl
)
}
existing.avatarUrl = existing.avatarUrl ?: normalized.avatarUrl
existing.profileUrl = existing.profileUrl ?: normalized.htmlUrl
existing.mobileContributions += normalized.contributions
}
return contributorsByLogin.values
.map { contributor ->
GitHubContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.profileUrl,
totalContributions = contributor.tvContributions + contributor.mobileContributions,
tvContributions = contributor.tvContributions,
mobileContributions = contributor.mobileContributions
)
}
.sortedWith(
compareByDescending<GitHubContributor> { it.totalContributions }
.thenByDescending { it.tvContributions }
.thenByDescending { it.mobileContributions }
.thenBy { it.login.lowercase() }
)
}
private fun GitHubContributorDto.toNormalizedContributor(): NormalizedContributor? {
val normalizedLogin = login?.trim().orEmpty()
val normalizedContributions = contributions ?: 0
if (normalizedLogin.isBlank() || normalizedContributions <= 0) return null
if (type.equals("Bot", ignoreCase = true)) return null
return NormalizedContributor(
login = normalizedLogin,
avatarUrl = avatarUrl?.takeIf { it.isNotBlank() },
htmlUrl = htmlUrl?.takeIf { it.isNotBlank() },
contributions = normalizedContributions
)
}
private data class NormalizedContributor(
val login: String,
val avatarUrl: String?,
val htmlUrl: String?,
val contributions: Int
)
private data class MutableGitHubContributor(
val login: String,
var avatarUrl: String?,
var profileUrl: String?,
var tvContributions: Int = 0,
var mobileContributions: Int = 0
)
private companion object {
const val OWNER = "tapframe"
const val TV_REPOSITORY = "NuvioTV"
const val MOBILE_REPOSITORY = "nuviomobile"
}
}

View file

@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -41,7 +42,10 @@ class LibraryRepositoryImpl @Inject constructor(
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var syncJob: Job? = null
var isSyncingFromRemote = false
private val _isSyncingFromRemote = MutableStateFlow(false)
var isSyncingFromRemote: Boolean
get() = _isSyncingFromRemote.value
set(value) { _isSyncingFromRemote.value = value }
var hasCompletedInitialPull = false
private fun triggerRemoteSync() {
@ -66,7 +70,7 @@ class LibraryRepositoryImpl @Inject constructor(
if (mode == LibrarySourceMode.TRAKT) {
traktLibraryService.observeIsRefreshing()
} else {
flowOf(false)
_isSyncingFromRemote
}
}
.distinctUntilChanged()

View file

@ -13,6 +13,11 @@ import com.nuvio.tv.domain.repository.AddonRepository
import com.nuvio.tv.domain.repository.MetaRepository
import com.nuvio.tv.R
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
@ -31,10 +36,29 @@ class MetaRepositoryImpl @Inject constructor(
private const val TAG = "MetaRepository"
}
private enum class MetaFailureKind {
MISSING,
REQUEST_FAILED
}
private data class MetaAttemptFailure(
val addonName: String,
val kind: MetaFailureKind,
val detail: String
)
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// In-memory cache: "type:id" -> Meta
private val metaCache = ConcurrentHashMap<String, Meta>()
// Separate cache for full meta fetched from addons (bypasses catalog-level cache)
private val addonMetaCache = ConcurrentHashMap<String, Meta>()
private val primaryAddonMetaCache = ConcurrentHashMap<String, Meta>()
// In-flight deduplication: prevents concurrent coroutines from firing duplicate requests
private val inFlightMeta = ConcurrentHashMap<String, Deferred<Meta?>>()
private val inFlightAddonMeta = ConcurrentHashMap<String, Deferred<Meta?>>()
private val inFlightPrimaryMeta = ConcurrentHashMap<String, Deferred<Meta?>>()
override fun getMeta(
addonBaseUrl: String,
@ -50,21 +74,29 @@ class MetaRepositoryImpl @Inject constructor(
emit(NetworkResult.Loading)
val url = buildMetaUrl(addonBaseUrl, type, id)
when (val result = safeApiCall { api.getMeta(url) }) {
is NetworkResult.Success -> {
val metaDto = result.data.meta
if (metaDto != null) {
val episodeLabel = context.getString(R.string.episodes_episode)
val meta = metaDto.toDomain(episodeLabel)
metaCache[cacheKey] = meta
emit(NetworkResult.Success(meta))
} else {
emit(NetworkResult.Error("Meta not found"))
val deferred = inFlightMeta.getOrPut(cacheKey) {
repositoryScope.async {
try {
when (val result = safeApiCall { api.getMeta(url) }) {
is NetworkResult.Success -> {
val metaDto = result.data.meta ?: return@async null
val meta = metaDto.toDomain(context.getString(R.string.episodes_episode))
metaCache[cacheKey] = meta
meta
}
else -> null
}
} finally {
inFlightMeta.remove(cacheKey)
}
}
is NetworkResult.Error -> emit(result)
NetworkResult.Loading -> { /* Already emitted */ }
}
val meta = deferred.await()
if (meta != null) {
emit(NetworkResult.Success(meta))
} else {
emit(NetworkResult.Error(context.getString(R.string.error_meta_not_found)))
}
}
@ -84,6 +116,8 @@ class MetaRepositoryImpl @Inject constructor(
val requestedType = type.trim()
val inferredType = inferCanonicalType(requestedType, id)
val attemptedFailures = mutableListOf<MetaAttemptFailure>()
val attemptedAddonNames = linkedSetOf<String>()
val metaResourceAddons = addons.filter { addon ->
addon.resources.any { it.name == "meta" }
}
@ -117,69 +151,156 @@ class MetaRepositoryImpl @Inject constructor(
if (prioritizedCandidates.isEmpty()) {
// Last resort: try addons that declare the raw type (legacy behavior).
val fallbackAddons = addons.filter { addon ->
addon.rawTypes.any { it.equals(requestedType, ignoreCase = true) }
addon.rawTypes.any { it.equals(requestedType, ignoreCase = true) } &&
addon.resources.any { it.name == "meta" }
}
for (addon in fallbackAddons) {
attemptedAddonNames += addon.displayName
val url = buildMetaUrl(addon.baseUrl, requestedType, id)
when (val result = safeApiCall { api.getMeta(url) }) {
is NetworkResult.Success -> {
val metaDto = result.data.meta
if (metaDto != null) {
val episodeLabel = context.getString(R.string.episodes_episode)
val meta = metaDto.toDomain(episodeLabel)
val meta = metaDto.toDomain(episodeLabel)
addonMetaCache[cacheKey] = meta
metaCache[cacheKey] = meta
emit(NetworkResult.Success(meta))
return@flow
} else {
attemptedFailures += buildMissingMetaFailure(addon)
}
}
else -> { /* Try next addon */ }
is NetworkResult.Error -> {
attemptedFailures += buildAddonFailure(addon, result)
}
NetworkResult.Loading -> { /* Try next addon */ }
}
}
emit(NetworkResult.Error("No addons support meta for type: $requestedType"))
val fallbackMessage = if (fallbackAddons.isEmpty()) {
context.getString(R.string.error_meta_no_supported_addon, requestedType)
} else {
buildAggregateFailureMessage(
type = requestedType,
id = id,
attemptedAddonNames = attemptedAddonNames.toList(),
failures = attemptedFailures
)
}
emit(NetworkResult.Error(fallbackMessage))
return@flow
}
// Try each candidate until we find meta.
for ((addon, candidateType) in prioritizedCandidates) {
val url = buildMetaUrl(addon.baseUrl, candidateType, id)
Log.d(
TAG,
"Trying meta addonId=${addon.id} addonName=${addon.name} type=$candidateType id=$id url=$url"
)
when (val result = safeApiCall { api.getMeta(url) }) {
is NetworkResult.Success -> {
val metaDto = result.data.meta
if (metaDto != null) {
val episodeLabel = context.getString(R.string.episodes_episode)
val meta = metaDto.toDomain(episodeLabel)
addonMetaCache[cacheKey] = meta
metaCache[cacheKey] = meta
Log.d(
TAG,
"Meta fetch success addonId=${addon.id} type=$candidateType id=$id"
)
emit(NetworkResult.Success(meta))
return@flow
val deferred = inFlightAddonMeta.getOrPut(cacheKey) {
repositoryScope.async {
try {
for ((addon, candidateType) in prioritizedCandidates) {
val url = buildMetaUrl(addon.baseUrl, candidateType, id)
Log.d(TAG, "Trying meta addonId=${addon.id} addonName=${addon.name} type=$candidateType id=$id url=$url")
when (val result = safeApiCall { api.getMeta(url) }) {
is NetworkResult.Success -> {
val metaDto = result.data.meta
if (metaDto != null) {
val meta = metaDto.toDomain(context.getString(R.string.episodes_episode))
addonMetaCache[cacheKey] = meta
metaCache[cacheKey] = meta
Log.d(TAG, "Meta fetch success addonId=${addon.id} type=$candidateType id=$id")
return@async meta
}
Log.d(TAG, "Meta response was null addonId=${addon.id} type=$candidateType id=$id")
}
else -> { /* try next */ }
}
}
Log.d(
TAG,
"Meta response was null addonId=${addon.id} type=$candidateType id=$id"
)
null
} finally {
inFlightAddonMeta.remove(cacheKey)
}
is NetworkResult.Error -> {
Log.w(
TAG,
"Meta fetch failed addonId=${addon.id} type=$candidateType id=$id code=${result.code} message=${result.message}"
)
}
NetworkResult.Loading -> { /* no-op */ }
}
}
emit(NetworkResult.Error("Meta not found in any addon"))
val meta = deferred.await()
if (meta != null) {
emit(NetworkResult.Success(meta))
} else {
emit(
NetworkResult.Error(
buildAggregateFailureMessage(
type = requestedType,
id = id,
attemptedAddonNames = attemptedAddonNames.toList(),
failures = attemptedFailures
)
)
)
}
}
override fun getMetaFromPrimaryAddon(
type: String,
id: String
): Flow<NetworkResult<Meta>> = flow {
val cacheKey = "$type:$id"
primaryAddonMetaCache[cacheKey]?.let { cached ->
emit(NetworkResult.Success(cached))
return@flow
}
emit(NetworkResult.Loading)
val addons = addonRepository.getInstalledAddons().first()
val requestedType = type.trim()
val inferredType = inferCanonicalType(requestedType, id)
val candidate = selectPrimaryMetaCandidate(
addons = addons,
requestedType = requestedType,
inferredType = inferredType
)
if (candidate == null) {
emit(NetworkResult.Error(context.getString(R.string.error_meta_no_supported_addon, requestedType)))
return@flow
}
val (addon, candidateType) = candidate
val url = buildMetaUrl(addon.baseUrl, candidateType, id)
Log.d(
TAG,
"Trying primary meta addonId=${addon.id} addonName=${addon.name} type=$candidateType id=$id url=$url"
)
val deferred = inFlightPrimaryMeta.getOrPut(cacheKey) {
repositoryScope.async {
try {
when (val result = safeApiCall { api.getMeta(url) }) {
is NetworkResult.Success -> {
val metaDto = result.data.meta ?: return@async null
val meta = metaDto.toDomain(context.getString(R.string.episodes_episode))
primaryAddonMetaCache[cacheKey] = meta
metaCache[cacheKey] = meta
meta
}
else -> null
}
} finally {
inFlightPrimaryMeta.remove(cacheKey)
}
}
}
val meta = deferred.await()
if (meta != null) {
emit(NetworkResult.Success(meta))
} else {
emit(NetworkResult.Error(buildAggregateFailureMessage(
type = requestedType,
id = id,
attemptedAddonNames = listOf(addon.displayName),
failures = listOf(buildMissingMetaFailure(addon))
)))
}
}
private fun buildMetaUrl(baseUrl: String, type: String, id: String): String {
@ -217,12 +338,108 @@ class MetaRepositoryImpl @Inject constructor(
}
}
private fun selectPrimaryMetaCandidate(
addons: List<Addon>,
requestedType: String,
inferredType: String
): Pair<Addon, String>? {
addons.forEach { addon ->
if (addon.supportsMetaType(requestedType)) {
return addon to requestedType
}
}
if (!inferredType.equals(requestedType, ignoreCase = true)) {
addons.forEach { addon ->
if (addon.supportsMetaType(inferredType)) {
return addon to inferredType
}
}
}
val topMetaAddon = addons.firstOrNull { addon ->
addon.resources.any { it.name == "meta" }
} ?: return null
val fallbackType = when {
topMetaAddon.supportsMetaType(requestedType) -> requestedType
topMetaAddon.supportsMetaType(inferredType) -> inferredType
else -> inferredType.ifBlank { requestedType }
}
return topMetaAddon to fallbackType
}
private fun encodePathSegment(value: String): String {
return URLEncoder.encode(value, "UTF-8").replace("+", "%20")
}
private fun buildMissingMetaFailure(addon: Addon): MetaAttemptFailure {
return MetaAttemptFailure(
addonName = addon.displayName,
kind = MetaFailureKind.MISSING,
detail = "returned no metadata for this id"
)
}
private fun buildAddonFailure(addon: Addon, error: NetworkResult.Error): MetaAttemptFailure {
if (error.code == 404 || error.message.equals("Not Found", ignoreCase = true)) {
return buildMissingMetaFailure(addon)
}
val normalizedReason = when {
error.message.contains("Unable to resolve host", ignoreCase = true) ->
"could not reach the addon server"
error.message.contains("Failed to connect", ignoreCase = true) ->
"connection to the addon failed"
error.message.contains("timeout", ignoreCase = true) ->
"the addon request timed out"
error.message.contains("CLEARTEXT communication", ignoreCase = true) ->
"the addon uses an insecure HTTP connection blocked by Android"
error.message.isBlank() ->
"the addon request failed"
else -> error.message.replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}
}
val httpSuffix = error.code?.let { " (HTTP $it)" } ?: ""
return MetaAttemptFailure(
addonName = addon.displayName,
kind = MetaFailureKind.REQUEST_FAILED,
detail = "$normalizedReason$httpSuffix"
)
}
private fun buildAggregateFailureMessage(
type: String,
id: String,
attemptedAddonNames: List<String>,
failures: List<MetaAttemptFailure>
): String {
if (attemptedAddonNames.isEmpty()) {
return context.getString(R.string.error_meta_no_addon_for_id, id, type)
}
val triedAddons = attemptedAddonNames.joinToString(", ")
val missingOnly = failures.isNotEmpty() && failures.all { it.kind == MetaFailureKind.MISSING }
return if (missingOnly) {
context.getString(R.string.error_meta_tried_none, triedAddons, id, type)
} else {
val issueSummary = failures
.filter { it.kind == MetaFailureKind.REQUEST_FAILED }
.distinctBy { it.addonName to it.detail }
.take(3)
.joinToString("; ") { "${it.addonName}: ${it.detail}" }
if (issueSummary.isBlank()) {
context.getString(R.string.error_meta_tried_generic, triedAddons, id, type)
} else {
context.getString(R.string.error_meta_tried_issues, triedAddons, id, type, issueSummary)
}
}
}
override fun clearCache() {
metaCache.clear()
addonMetaCache.clear()
primaryAddonMetaCache.clear()
inFlightMeta.clear()
inFlightAddonMeta.clear()
inFlightPrimaryMeta.clear()
}
}

View file

@ -2,80 +2,158 @@ package com.nuvio.tv.data.repository
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.local.AnimeSkipSettingsDataStore
import com.nuvio.tv.data.remote.api.AniSkipApi
import com.nuvio.tv.data.remote.api.AnimeSkipApi
import com.nuvio.tv.data.remote.api.AnimeSkipRequest
import com.nuvio.tv.data.remote.api.ArmApi
import com.nuvio.tv.data.remote.api.ArmEntry
import com.nuvio.tv.data.remote.api.IntroDbApi
import com.nuvio.tv.data.remote.api.IntroDbSegment
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.firstOrNull
data class SkipInterval(
val startTime: Double, // seconds
val endTime: Double, // seconds
val type: String, // "intro", "op", "ed", "recap", "outro", "mixed-op", "mixed-ed"
val provider: String // "introdb" or "aniskip"
val provider: String // "introdb", "aniskip", "animeskip"
)
@Singleton
class SkipIntroRepository @Inject constructor(
private val introDbApi: IntroDbApi,
private val aniSkipApi: AniSkipApi,
private val armApi: ArmApi
private val animeSkipApi: AnimeSkipApi,
private val armApi: ArmApi,
private val animeSkipSettingsDataStore: AnimeSkipSettingsDataStore
) {
private val cache = ConcurrentHashMap<String, List<SkipInterval>>()
private val malIdCache = ConcurrentHashMap<String, String>()
private val imdbEntriesCache = ConcurrentHashMap<String, List<ArmEntry>>()
private val animeSkipShowIdCache = ConcurrentHashMap<String, String>()
private val introDbConfigured = BuildConfig.INTRODB_API_URL.isNotEmpty()
suspend fun getSkipIntervals(
imdbId: String?,
season: Int,
episode: Int
): List<SkipInterval> {
suspend fun getSkipIntervals(imdbId: String?, season: Int, episode: Int): List<SkipInterval> {
if (imdbId == null) return emptyList()
val cacheKey = "$imdbId:$season:$episode"
cache[cacheKey]?.let { return it }
// 1. Try IntroDB first (TV shows)
if (introDbConfigured) {
val introDbResult = fetchFromIntroDb(imdbId, season, episode)
if (introDbResult.isNotEmpty()) {
cache[cacheKey] = introDbResult
return introDbResult
}
val result = fetchFromIntroDb(imdbId, season, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
// 2. Try AniSkip (anime) - resolve MAL ID from IMDB via ARM
val malId = resolveMalId(imdbId, season)
val entries = resolveImdbEntries(imdbId)
val malId = entries.getOrNull(season - 1)?.myanimelist?.toString()
?: entries.firstOrNull()?.myanimelist?.toString()
if (malId != null) {
val aniSkipResult = fetchFromAniSkip(malId, episode)
if (aniSkipResult.isNotEmpty()) {
cache[cacheKey] = aniSkipResult
return aniSkipResult
}
val result = fetchFromAniSkip(malId, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
// Cache empty result to avoid repeated lookups
cache[cacheKey] = emptyList()
return emptyList()
// AnimeSkip: try season-specific AniList ID first, then season-1 as fallback with season filter
val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
for ((anilistId, seasonFilter) in listOfNotNull(
seasonAnilistId?.let { it to null },
if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
)) {
val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
return emptyList<SkipInterval>().also { cache[cacheKey] = it }
}
suspend fun getSkipIntervalsForMal(malId: String, episode: Int): List<SkipInterval> {
val cacheKey = "mal:$malId:$episode"
cache[cacheKey]?.let { return it }
val result = fetchFromAniSkip(malId, episode)
cache[cacheKey] = result
return result
val aniSkipResult = fetchFromAniSkip(malId, episode)
if (aniSkipResult.isNotEmpty()) return aniSkipResult.also { cache[cacheKey] = it }
val imdbId = try {
armApi.resolveMalToImdb(malId = malId).takeIf { it.isSuccessful }?.body()?.imdb
} catch (e: Exception) { null }
if (imdbId != null) {
val entries = resolveImdbEntries(imdbId)
val season = entries.indexOfFirst { it.myanimelist == malId.toIntOrNull() } + 1
if (introDbConfigured) {
val result = fetchFromIntroDb(imdbId, season, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
for ((anilistId, seasonFilter) in listOfNotNull(
seasonAnilistId?.let { it to null },
if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
)) {
val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
} else {
val anilistId = try {
armApi.resolveMalToAnilist(malId = malId).takeIf { it.isSuccessful }?.body()?.anilist?.toString()
} catch (e: Exception) { null }
if (anilistId != null) {
val result = fetchFromAnimeSkip(anilistId, episode, season = null)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
}
return emptyList<SkipInterval>().also { cache[cacheKey] = it }
}
suspend fun getSkipIntervalsForKitsu(kitsuId: String, episode: Int): List<SkipInterval> {
val cacheKey = "kitsu:$kitsuId:$episode"
cache[cacheKey]?.let { return it }
val malId = resolveKitsuId(kitsuId)
val result = if (malId != null) fetchFromAniSkip(malId, episode) else emptyList()
cache[cacheKey] = result
return result
val malId = try {
armApi.resolveKitsuToMal(kitsuId = kitsuId)
.takeIf { it.isSuccessful }?.body()?.myanimelist?.toString()
} catch (e: Exception) { null }
if (malId != null) {
val result = fetchFromAniSkip(malId, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
val imdbId = try {
armApi.resolveKitsuToImdb(kitsuId = kitsuId).takeIf { it.isSuccessful }?.body()?.imdb
} catch (e: Exception) { null }
if (imdbId != null) {
val entries = resolveImdbEntries(imdbId)
val season = entries.indexOfFirst { it.kitsu == kitsuId.toIntOrNull() } + 1
if (introDbConfigured) {
val result = fetchFromIntroDb(imdbId, season, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
for ((anilistId, seasonFilter) in listOfNotNull(
seasonAnilistId?.let { it to null },
if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
)) {
val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
} else {
val anilistId = try {
armApi.resolveKitsuToAnilist(kitsuId = kitsuId).takeIf { it.isSuccessful }?.body()?.anilist?.toString()
} catch (e: Exception) { null }
if (anilistId != null) {
val result = fetchFromAnimeSkip(anilistId, episode, season = null)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
}
return emptyList<SkipInterval>().also { cache[cacheKey] = it }
}
private suspend fun fetchFromIntroDb(imdbId: String, season: Int, episode: Int): List<SkipInterval> {
@ -84,9 +162,9 @@ class SkipIntroRepository @Inject constructor(
if (response.isSuccessful && response.body() != null) {
val data = response.body()!!
listOfNotNull(
data.intro.toSkipIntervalOrNull(type = "intro"),
data.recap.toSkipIntervalOrNull(type = "recap"),
data.outro.toSkipIntervalOrNull(type = "outro")
data.intro.toSkipIntervalOrNull("intro"),
data.recap.toSkipIntervalOrNull("recap"),
data.outro.toSkipIntervalOrNull("outro")
)
} else emptyList()
} catch (e: Exception) {
@ -97,17 +175,10 @@ class SkipIntroRepository @Inject constructor(
private fun IntroDbSegment?.toSkipIntervalOrNull(type: String): SkipInterval? {
if (this == null) return null
val start = startSec ?: startMs?.let { it / 1000.0 }
val end = endSec ?: endMs?.let { it / 1000.0 }
if (start == null || end == null || end <= start) return null
return SkipInterval(
startTime = start,
endTime = end,
type = type,
provider = "introdb"
)
return SkipInterval(startTime = start, endTime = end, type = type, provider = "introdb")
}
private suspend fun fetchFromAniSkip(malId: String, episode: Int): List<SkipInterval> {
@ -130,40 +201,76 @@ class SkipIntroRepository @Inject constructor(
}
}
private suspend fun resolveMalId(imdbId: String, season: Int): String? {
val cacheKey = "$imdbId:$season"
val cached = malIdCache[cacheKey]
if (cached != null) return cached.takeIf { it != NO_MAL_ID }
// season: null when anilistId is season-specific; pass season number when using season-1 fallback ID
private suspend fun fetchFromAnimeSkip(anilistId: String, episode: Int, season: Int?): List<SkipInterval> {
val clientId = animeSkipSettingsDataStore.clientId.firstOrNull()?.trim()
if (clientId.isNullOrBlank()) return emptyList()
val enabled = animeSkipSettingsDataStore.enabled.firstOrNull() ?: false
if (!enabled) return emptyList()
return try {
val showIds = resolveAnimeSkipShowIds(anilistId, clientId)
if (showIds.isEmpty()) return emptyList()
val malId = try {
val response = armApi.resolveByImdb(imdbId)
if (response.isSuccessful) {
val entries = response.body() ?: emptyList()
(entries.getOrNull(season - 1) ?: entries.firstOrNull())?.myanimelist?.toString()
} else null
} catch (e: Exception) { null }
for (showId in showIds) {
val episodesResponse = animeSkipApi.query(
clientId = clientId,
body = AnimeSkipRequest(
query = "{ findEpisodesByShowId(showId: \"$showId\") { season number timestamps { at type { name } } } }"
)
)
if (!episodesResponse.isSuccessful) continue
malIdCache[cacheKey] = malId ?: NO_MAL_ID
return malId
val episodes = episodesResponse.body()?.data?.findEpisodesByShowId ?: continue
val targetEpisode = episodes.firstOrNull { ep ->
ep.number?.toIntOrNull() == episode &&
(season == null || ep.season?.toIntOrNull() == season)
} ?: continue
val sorted = (targetEpisode.timestamps ?: continue).sortedBy { it.at }
val result = sorted.mapIndexedNotNull { i, ts ->
val endTime = sorted.getOrNull(i + 1)?.at ?: Double.MAX_VALUE
val type = when (ts.type.name.lowercase()) {
"intro", "new intro" -> "op"
"credits" -> "ed"
"recap" -> "recap"
else -> return@mapIndexedNotNull null
}
SkipInterval(startTime = ts.at, endTime = endTime, type = type, provider = "animeskip")
}
if (result.isNotEmpty()) return result
}
emptyList()
} catch (e: Exception) {
Log.d("SkipIntro", "AnimeSkip: error for anilist $anilistId ep $episode: ${e.message}")
emptyList()
}
}
private suspend fun resolveKitsuId(kitsuId: String): String? {
val cacheKey = "kitsu:$kitsuId"
val cached = malIdCache[cacheKey]
if (cached != null) return cached.takeIf { it != NO_MAL_ID }
private suspend fun resolveAnimeSkipShowIds(anilistId: String, clientId: String): List<String> {
animeSkipShowIdCache[anilistId]?.let { cached ->
return if (cached == NO_ID) emptyList() else listOf(cached)
}
val showIds = try {
animeSkipApi.query(
clientId = clientId,
body = AnimeSkipRequest(
query = "{ findShowsByExternalId(service: ANILIST, serviceId: \"$anilistId\") { id } }"
)
).body()?.data?.findShowsByExternalId?.map { it.id } ?: emptyList()
} catch (e: Exception) { emptyList() }
if (showIds.size == 1) animeSkipShowIdCache[anilistId] = showIds[0]
else if (showIds.isEmpty()) animeSkipShowIdCache[anilistId] = NO_ID
return showIds
}
val malId = try {
val response = armApi.resolveByKitsu(kitsuId = kitsuId)
if (response.isSuccessful) {
response.body()?.myanimelist?.toString()
} else null
} catch (e: Exception) { null }
malIdCache[cacheKey] = malId ?: NO_MAL_ID
return malId
private suspend fun resolveImdbEntries(imdbId: String): List<ArmEntry> {
imdbEntriesCache[imdbId]?.let { return it }
return try {
armApi.resolveImdbToAll(imdbId).takeIf { it.isSuccessful }?.body() ?: emptyList()
} catch (e: Exception) { emptyList() }.also { imdbEntriesCache[imdbId] = it }
}
companion object {
private const val NO_MAL_ID = "__none__"
private const val NO_ID = "__none__"
}
}

View file

@ -1,6 +1,8 @@
package com.nuvio.tv.data.repository
import android.content.Context
import android.util.Log
import com.nuvio.tv.R
import com.nuvio.tv.core.network.NetworkResult
import com.nuvio.tv.core.network.safeApiCall
import com.nuvio.tv.core.plugin.PluginManager
@ -14,6 +16,7 @@ import com.nuvio.tv.domain.model.Stream
import com.nuvio.tv.domain.model.StreamBehaviorHints
import com.nuvio.tv.domain.repository.AddonRepository
import com.nuvio.tv.domain.repository.StreamRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
@ -28,11 +31,22 @@ import javax.inject.Inject
private const val TAG = "StreamRepositoryImpl"
class StreamRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val api: AddonApi,
private val addonRepository: AddonRepository,
private val pluginManager: PluginManager,
private val tmdbService: TmdbService
) : StreamRepository {
private enum class StreamFailureKind {
MISSING,
REQUEST_FAILED
}
private data class StreamAttemptFailure(
val addonName: String,
val kind: StreamFailureKind,
val detail: String
)
override fun getStreamsFromAllAddons(
type: String,
@ -53,6 +67,10 @@ class StreamRepositoryImpl @Inject constructor(
// Convert IMDB ID to TMDB ID if needed for plugins
val tmdbId = tmdbService.ensureTmdbId(videoId, type)
Log.d(TAG, "Video ID: $videoId -> TMDB ID: $tmdbId (type: $type)")
val attemptedAddonNames = streamAddons.map { it.displayName }
val attemptedFailures = java.util.Collections.synchronizedList(
mutableListOf<StreamAttemptFailure>()
)
// Accumulate results as they arrive
val accumulatedResults = mutableListOf<AddonStreams>()
@ -83,13 +101,23 @@ class StreamRepositoryImpl @Inject constructor(
streams = namedStreams
)
)
} else {
attemptedFailures += buildMissingStreamFailure(addon)
}
}
else -> { /* No streams */ }
is NetworkResult.Error -> {
attemptedFailures += buildAddonFailure(addon, streamsResult)
}
NetworkResult.Loading -> Unit
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "Addon ${addon.name} failed: ${e.message}")
attemptedFailures += StreamAttemptFailure(
addonName = addon.displayName,
kind = StreamFailureKind.REQUEST_FAILED,
detail = e.message ?: "the addon request failed"
)
} finally {
completedJobs++
if (completedJobs >= totalJobs) {
@ -136,7 +164,17 @@ class StreamRepositoryImpl @Inject constructor(
// Emit final result (even if empty)
if (accumulatedResults.isEmpty()) {
emit(NetworkResult.Success(emptyList()))
val errorMessage = buildAggregateFailureMessage(
type = type,
id = videoId,
attemptedAddonNames = attemptedAddonNames,
failures = attemptedFailures.toList()
)
if (errorMessage != null) {
emit(NetworkResult.Error(errorMessage))
} else {
emit(NetworkResult.Success(emptyList()))
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@ -298,6 +336,70 @@ class StreamRepositoryImpl @Inject constructor(
}
}
private fun buildMissingStreamFailure(addon: Addon): StreamAttemptFailure {
return StreamAttemptFailure(
addonName = addon.displayName,
kind = StreamFailureKind.MISSING,
detail = "returned no streams for this id"
)
}
private fun buildAddonFailure(addon: Addon, error: NetworkResult.Error): StreamAttemptFailure {
if (error.code == 404 || error.message.equals("Not Found", ignoreCase = true)) {
return buildMissingStreamFailure(addon)
}
val normalizedReason = when {
error.message.contains("Unable to resolve host", ignoreCase = true) ->
"could not reach the addon server"
error.message.contains("Failed to connect", ignoreCase = true) ->
"connection to the addon failed"
error.message.contains("timeout", ignoreCase = true) ->
"the addon request timed out"
error.message.contains("CLEARTEXT communication", ignoreCase = true) ->
"the addon uses an insecure HTTP connection blocked by Android"
error.message.isBlank() ->
"the addon request failed"
else -> error.message.replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}
}
val httpSuffix = error.code?.let { " (HTTP $it)" } ?: ""
return StreamAttemptFailure(
addonName = addon.displayName,
kind = StreamFailureKind.REQUEST_FAILED,
detail = "$normalizedReason$httpSuffix"
)
}
private fun buildAggregateFailureMessage(
type: String,
id: String,
attemptedAddonNames: List<String>,
failures: List<StreamAttemptFailure>
): String? {
if (attemptedAddonNames.isEmpty()) {
return context.getString(R.string.error_stream_no_supported_addon, type)
}
val triedAddons = attemptedAddonNames.joinToString(", ")
val missingOnly = failures.isNotEmpty() && failures.all { it.kind == StreamFailureKind.MISSING }
if (failures.isEmpty() || missingOnly) {
return context.getString(R.string.error_stream_tried_none, triedAddons, id, type)
}
val issueSummary = failures
.filter { it.kind == StreamFailureKind.REQUEST_FAILED }
.distinctBy { it.addonName to it.detail }
.take(3)
.joinToString("; ") { "${it.addonName}: ${it.detail}" }
return if (issueSummary.isBlank()) {
context.getString(R.string.error_stream_tried_generic, triedAddons, id, type)
} else {
context.getString(R.string.error_stream_tried_issues, triedAddons, id, type, issueSummary)
}
}
private fun encodePathSegment(value: String): String {
return URLEncoder.encode(value, "UTF-8").replace("+", "%20")
}

View file

@ -24,7 +24,7 @@ class SubtitleRepositoryImpl @Inject constructor(
companion object {
private const val TAG = "SubtitleRepository"
private const val PER_ADDON_TIMEOUT_MS = 8_000L
private const val PER_ADDON_TIMEOUT_MS = 15_000L
}
override suspend fun getSubtitles(
@ -35,8 +35,9 @@ class SubtitleRepositoryImpl @Inject constructor(
videoSize: Long?,
filename: String?
): List<Subtitle> = withContext(Dispatchers.IO) {
val requestType = canonicalSubtitleType(type)
val startedAtMs = System.currentTimeMillis()
Log.d(TAG, "Fetching subtitles for type=$type, id=$id, videoId=$videoId")
Log.d(TAG, "Fetching subtitles for type=$requestType, id=$id, videoId=$videoId")
// Get installed addons
val addons = try {
@ -51,7 +52,7 @@ class SubtitleRepositoryImpl @Inject constructor(
// Filter addons that support subtitles resource
val subtitleAddons = addons.filter { addon ->
addon.resources.any { resource ->
isSubtitleResource(resource.name) && supportsType(resource, type, id)
isSubtitleResource(resource.name) && supportsType(resource, requestType, id)
}
}
@ -91,10 +92,14 @@ class SubtitleRepositoryImpl @Inject constructor(
)
result
}
private fun canonicalSubtitleType(type: String): String {
return if (type.equals("tv", ignoreCase = true)) "series" else type.lowercase()
}
private fun supportsType(resource: com.nuvio.tv.domain.model.AddonResource, type: String, id: String): Boolean {
// Check if type is supported
if (resource.types.isNotEmpty() && !resource.types.contains(type)) {
if (resource.types.isNotEmpty() && resource.types.none { it.equals(type, ignoreCase = true) }) {
return false
}
@ -121,7 +126,8 @@ class SubtitleRepositoryImpl @Inject constructor(
videoSize: Long?,
filename: String?
): List<Subtitle> {
val actualId = if (type == "series" && videoId != null) {
val normalizedType = canonicalSubtitleType(type)
val actualId = if (normalizedType == "series" && videoId != null) {
// For series, use videoId which includes season/episode
videoId
} else {
@ -132,9 +138,9 @@ class SubtitleRepositoryImpl @Inject constructor(
val baseUrl = addon.baseUrl.trimEnd('/')
val extraParams = buildExtraParams(videoHash, videoSize, filename)
val subtitleUrl = if (extraParams.isNotEmpty()) {
"$baseUrl/subtitles/$type/$actualId/$extraParams.json"
"$baseUrl/subtitles/$normalizedType/$actualId/$extraParams.json"
} else {
"$baseUrl/subtitles/$type/$actualId.json"
"$baseUrl/subtitles/$normalizedType/$actualId.json"
}
Log.d(TAG, "Fetching subtitles from ${addon.name}: $subtitleUrl")

View file

@ -0,0 +1,53 @@
package com.nuvio.tv.data.repository
import com.nuvio.tv.data.remote.api.DonationsApi
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
data class SupporterDonation(
val key: String,
val name: String,
val date: String,
val message: String?,
val sortTimestamp: Long
)
@Singleton
class SupportersRepository @Inject constructor(
private val donationsApi: DonationsApi
) {
suspend fun getSupporters(limit: Int = 200): Result<List<SupporterDonation>> = runCatching {
val response = donationsApi.getDonations(limit = limit)
if (!response.isSuccessful) {
error("Donations API error: ${response.code()}")
}
response.body()
?.donations
.orEmpty()
.mapNotNull { donation ->
val name = donation.name?.trim().orEmpty()
val date = donation.date?.trim().orEmpty()
if (name.isBlank() || date.isBlank()) return@mapNotNull null
SupporterDonation(
key = "$name|$date",
name = name,
date = date,
message = donation.message?.trim()?.takeIf { it.isNotBlank() },
sortTimestamp = parseTimestamp(date)
)
}
.sortedByDescending { it.sortTimestamp }
.mapIndexed { index, donation ->
donation.copy(key = "${donation.key}#$index")
}
}
private fun parseTimestamp(rawDate: String): Long {
return runCatching { Instant.parse(rawDate).toEpochMilli() }
.getOrDefault(Long.MIN_VALUE)
}
}

View file

@ -1,6 +1,7 @@
package com.nuvio.tv.data.repository
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.local.AuthSessionNoticeDataStore
import com.nuvio.tv.data.local.TraktAuthDataStore
import com.nuvio.tv.data.local.TraktAuthState
import com.nuvio.tv.data.remote.api.TraktApi
@ -33,7 +34,8 @@ sealed interface TraktTokenPollResult {
@Singleton
class TraktAuthService @Inject constructor(
private val traktApi: TraktApi,
private val traktAuthDataStore: TraktAuthDataStore
private val traktAuthDataStore: TraktAuthDataStore,
private val authSessionNoticeDataStore: AuthSessionNoticeDataStore
) {
private val refreshLeewaySeconds = 60L
private val writeRequestMutex = Mutex()
@ -175,6 +177,7 @@ class TraktAuthService @Inject constructor(
val tokenBody = response.body()
if (response.isSuccessful && tokenBody != null) {
traktAuthDataStore.saveToken(tokenBody)
authSessionNoticeDataStore.markTraktAuthenticated()
traktAuthDataStore.clearDeviceFlow()
val user = fetchUserSettings()
return TraktTokenPollResult.Approved(user)
@ -237,6 +240,7 @@ class TraktAuthService @Inject constructor(
if (!response.isSuccessful || tokenBody == null) {
trace("refreshTokenIfNeeded: failed code=${response.code()}")
if (response.code() == 401 || response.code() == 403) {
authSessionNoticeDataStore.markUnexpectedTraktLogoutIfNeeded()
traktAuthDataStore.clearAuth()
tripCircuit("Token refresh returned ${response.code()}")
}
@ -244,6 +248,7 @@ class TraktAuthService @Inject constructor(
}
traktAuthDataStore.saveToken(tokenBody)
authSessionNoticeDataStore.markTraktAuthenticated()
trace("refreshTokenIfNeeded: success")
true
}
@ -264,6 +269,7 @@ class TraktAuthService @Inject constructor(
}
}
}
authSessionNoticeDataStore.markTraktExplicitLogout()
traktAuthDataStore.clearAuth()
}

View file

@ -0,0 +1,274 @@
package com.nuvio.tv.data.repository
import com.nuvio.tv.data.remote.api.TraktApi
import com.nuvio.tv.data.remote.dto.trakt.TraktCommentDto
import com.nuvio.tv.data.remote.dto.trakt.TraktIdsDto
import com.nuvio.tv.data.remote.dto.trakt.TraktSearchResultDto
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.Meta
import com.nuvio.tv.domain.model.TraktCommentReview
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
private const val COMMENTS_SORT = "likes"
private const val COMMENTS_LIMIT = 100
private const val COMMENTS_CACHE_TTL_MS = 10 * 60_000L
private val INLINE_SPOILER_REGEX = Regex(
"\\[spoiler\\].*?\\[/spoiler\\]",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE)
internal enum class TraktCommentsType(val apiValue: String) {
MOVIE("movie"),
SHOW("show")
}
internal data class ResolvedCommentsTarget(
val type: TraktCommentsType,
val pathId: String
)
data class TraktCommentsPage(
val items: List<TraktCommentReview>,
val currentPage: Int,
val pageCount: Int,
val itemCount: Int
)
@Singleton
class TraktCommentsService @Inject constructor(
private val traktApi: TraktApi,
private val traktAuthService: TraktAuthService
) {
private data class TimedCache(
val pages: Map<Int, List<TraktCommentReview>>,
val pageCount: Int,
val itemCount: Int,
val updatedAtMs: Long
)
private val cacheMutex = Mutex()
private val cache = mutableMapOf<String, TimedCache>()
suspend fun getCommentsPage(
meta: Meta,
fallbackItemId: String? = null,
fallbackItemType: String? = null,
page: Int = 1,
forceRefresh: Boolean = false
): TraktCommentsPage {
val target = resolveCommentsTarget(meta, fallbackItemId, fallbackItemType)
?: return TraktCommentsPage(
items = emptyList(),
currentPage = page,
pageCount = 0,
itemCount = 0
)
val cacheKey = "${target.type.apiValue}|${target.pathId}"
if (forceRefresh) {
cacheMutex.withLock {
cache.remove(cacheKey)
}
}
if (!forceRefresh) {
cacheMutex.withLock {
val cached = cache[cacheKey]
if (
cached != null &&
System.currentTimeMillis() - cached.updatedAtMs <= COMMENTS_CACHE_TTL_MS &&
cached.pages.containsKey(page)
) {
return TraktCommentsPage(
items = cached.pages.getValue(page),
currentPage = page,
pageCount = cached.pageCount,
itemCount = cached.itemCount
)
}
}
}
val response = traktAuthService.executeAuthorizedRequest { authHeader ->
when (target.type) {
TraktCommentsType.MOVIE -> traktApi.getMovieComments(
authorization = authHeader,
id = target.pathId,
sort = COMMENTS_SORT,
page = page,
limit = COMMENTS_LIMIT
)
TraktCommentsType.SHOW -> traktApi.getShowComments(
authorization = authHeader,
id = target.pathId,
sort = COMMENTS_SORT,
page = page,
limit = COMMENTS_LIMIT
)
}
} ?: throw IllegalStateException("Trakt comments request failed")
val comments = when {
response.code() == 404 -> emptyList()
!response.isSuccessful -> throw IllegalStateException("Failed to load Trakt reviews (${response.code()})")
else -> response.body().orEmpty()
}
val pageCount = response.headers()["X-Pagination-Page-Count"]?.toIntOrNull() ?: page
val itemCount = response.headers()["X-Pagination-Item-Count"]?.toIntOrNull() ?: comments.size
val selected = filterDisplayableComments(comments).map(::toReviewModel)
cacheMutex.withLock {
val cached = cache[cacheKey]
cache[cacheKey] = TimedCache(
pages = (cached?.pages.orEmpty() + (page to selected)),
pageCount = pageCount,
itemCount = itemCount,
updatedAtMs = System.currentTimeMillis()
)
}
return TraktCommentsPage(
items = selected,
currentPage = page,
pageCount = pageCount,
itemCount = itemCount
)
}
private suspend fun resolveCommentsTarget(
meta: Meta,
fallbackItemId: String?,
fallbackItemType: String?
): ResolvedCommentsTarget? {
val type = resolveCommentsType(meta = meta, fallbackItemType = fallbackItemType) ?: return null
val directPathId = resolveDirectPathId(meta = meta, fallbackItemId = fallbackItemId)
if (!directPathId.isNullOrBlank()) {
return ResolvedCommentsTarget(type = type, pathId = directPathId)
}
val tmdbId = resolveTmdbCandidate(meta = meta, fallbackItemId = fallbackItemId) ?: return null
val searchResponse = traktAuthService.executeAuthorizedRequest { authHeader ->
traktApi.searchById(
authorization = authHeader,
idType = "tmdb",
id = tmdbId.toString(),
type = type.apiValue
)
} ?: throw IllegalStateException("Trakt TMDB search request failed")
if (!searchResponse.isSuccessful) {
if (searchResponse.code() == 404) return null
throw IllegalStateException("Failed to resolve Trakt id (${searchResponse.code()})")
}
val resolvedPathId = searchResponse.body()
.orEmpty()
.firstOrNull { it.type.equals(type.apiValue, ignoreCase = true) }
?.toTraktPathId(type)
return resolvedPathId?.let { ResolvedCommentsTarget(type = type, pathId = it) }
}
private fun resolveCommentsType(meta: Meta, fallbackItemType: String?): TraktCommentsType? {
val normalizedType = listOf(meta.apiType, meta.rawType, fallbackItemType)
.firstNotNullOfOrNull { value ->
value?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
}
return when (meta.type) {
ContentType.MOVIE -> TraktCommentsType.MOVIE
ContentType.SERIES, ContentType.TV -> TraktCommentsType.SHOW
else -> when (normalizedType) {
"movie" -> TraktCommentsType.MOVIE
"series", "show", "tv" -> TraktCommentsType.SHOW
else -> null
}
}
}
private fun resolveDirectPathId(meta: Meta, fallbackItemId: String?): String? {
meta.imdbId?.takeIf { it.isNotBlank() }?.let { return it }
val metaIds = parseContentIds(meta.id)
metaIds.imdb?.takeIf { it.isNotBlank() }?.let { return it }
metaIds.trakt?.let { return it.toString() }
meta.slug?.takeIf { it.isNotBlank() }?.let { return it }
val fallbackIds = parseContentIds(fallbackItemId)
fallbackIds.imdb?.takeIf { it.isNotBlank() }?.let { return it }
fallbackIds.trakt?.let { return it.toString() }
return null
}
private fun resolveTmdbCandidate(meta: Meta, fallbackItemId: String?): Int? {
val metaIds = parseContentIds(meta.id)
if (metaIds.tmdb != null) return metaIds.tmdb
return parseContentIds(fallbackItemId).tmdb
}
}
internal fun filterDisplayableComments(comments: List<TraktCommentDto>): List<TraktCommentDto> {
return comments.filter { !it.comment.isNullOrBlank() }
}
internal fun containsInlineSpoilers(comment: String?): Boolean {
if (comment.isNullOrBlank()) return false
return INLINE_SPOILER_REGEX.containsMatchIn(comment)
}
internal fun stripInlineSpoilerMarkup(comment: String?): String {
if (comment.isNullOrBlank()) return ""
return comment
.replace(INLINE_SPOILER_TAG_REGEX, "")
.replace(Regex("\\s+"), " ")
.trim()
}
internal fun TraktSearchResultDto.toTraktPathId(expectedType: TraktCommentsType): String? {
val ids = when (expectedType) {
TraktCommentsType.MOVIE -> movie?.ids
TraktCommentsType.SHOW -> show?.ids
}
return ids.toBestCommentsPathId()
}
internal fun TraktIdsDto?.toBestCommentsPathId(): String? {
if (this == null) return null
return when {
!imdb.isNullOrBlank() -> imdb
trakt != null -> trakt.toString()
!slug.isNullOrBlank() -> slug
else -> null
}
}
private fun toReviewModel(dto: TraktCommentDto): TraktCommentReview {
val authorDisplayName = dto.user?.name
?.takeIf { it.isNotBlank() }
?: dto.user?.username
?.takeIf { it.isNotBlank() }
?: "Trakt user"
return TraktCommentReview(
id = dto.id,
authorDisplayName = authorDisplayName,
authorUsername = dto.user?.username?.takeIf { it.isNotBlank() },
comment = stripInlineSpoilerMarkup(dto.comment),
spoiler = dto.spoiler == true,
containsInlineSpoilers = containsInlineSpoilers(dto.comment),
review = dto.review == true,
likes = dto.likes ?: 0,
rating = dto.userStats?.rating,
createdAt = dto.createdAt,
updatedAt = dto.updatedAt
)
}

View file

@ -0,0 +1,102 @@
package com.nuvio.tv.data.repository
internal data class EpisodeMappingEntry(
val season: Int,
val episode: Int,
val title: String? = null,
val videoId: String? = null
)
internal fun remapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
requestedTitle: String? = null,
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>
): EpisodeMappingEntry? {
return remapEpisodeBetweenLists(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = requestedVideoId,
requestedTitle = requestedTitle,
sourceEpisodes = addonEpisodes,
targetEpisodes = traktEpisodes
)
}
internal fun reverseRemapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String? = null,
requestedTitle: String? = null,
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>
): EpisodeMappingEntry? {
return remapEpisodeBetweenLists(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = requestedVideoId,
requestedTitle = requestedTitle,
sourceEpisodes = traktEpisodes,
targetEpisodes = addonEpisodes
)
}
private fun remapEpisodeBetweenLists(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
requestedTitle: String?,
sourceEpisodes: List<EpisodeMappingEntry>,
targetEpisodes: List<EpisodeMappingEntry>
): EpisodeMappingEntry? {
if (sourceEpisodes.isEmpty() || targetEpisodes.isEmpty()) return null
val orderedSourceEpisodes = sourceEpisodes
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
val orderedTargetEpisodes = targetEpisodes
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
val currentSourceEpisode = requestedVideoId
?.takeIf { it.isNotBlank() }
?.let { videoId ->
orderedSourceEpisodes.firstOrNull { it.videoId == videoId }
}
?: orderedSourceEpisodes.firstOrNull {
it.season == requestedSeason && it.episode == requestedEpisode
}
?: return null
val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title)
if (isUsefulEpisodeTitle(normalizedTitle)) {
val titleMatches = orderedTargetEpisodes.filter {
normalizeEpisodeTitle(it.title) == normalizedTitle
}
if (titleMatches.size == 1) {
return titleMatches.first()
}
}
val sourceIndex = orderedSourceEpisodes.indexOf(currentSourceEpisode)
if (sourceIndex !in orderedTargetEpisodes.indices) return null
return orderedTargetEpisodes[sourceIndex]
}
private fun normalizeEpisodeTitle(title: String?): String {
return title
.orEmpty()
.lowercase()
.replace(Regex("[^a-z0-9]+"), " ")
.trim()
.replace(Regex("\\s+"), " ")
}
private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean {
if (normalizedTitle.isBlank()) return false
if (normalizedTitle.matches(Regex("episode \\d+"))) return false
if (normalizedTitle.matches(Regex("ep \\d+"))) return false
if (normalizedTitle.matches(Regex("e \\d+"))) return false
return true
}

View file

@ -0,0 +1,300 @@
package com.nuvio.tv.data.repository
import android.util.Log
import com.nuvio.tv.core.network.NetworkResult
import com.nuvio.tv.data.remote.api.TraktApi
import com.nuvio.tv.domain.model.Meta
import com.nuvio.tv.domain.model.Video
import com.nuvio.tv.domain.repository.MetaRepository
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TraktEpisodeMappingService @Inject constructor(
private val traktApi: TraktApi,
private val traktAuthService: TraktAuthService,
private val metaRepository: MetaRepository
) {
companion object {
private const val TAG = "TraktEpMapSvc"
}
private val cacheMutex = Mutex()
private val mappingCache = mutableMapOf<String, EpisodeMappingEntry>()
private val addonEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
private val traktEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
private val reverseMappingCache = mutableMapOf<String, EpisodeMappingEntry>()
internal suspend fun prefetchEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?
): EpisodeMappingEntry? {
return resolveEpisodeMapping(contentId, contentType, videoId, season, episode)
}
internal suspend fun resolveAddonEpisodeMapping(
contentId: String?,
contentType: String?,
season: Int?,
episode: Int?,
episodeTitle: String? = null
): EpisodeMappingEntry? {
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val reverseKey = reverseCacheKey(
contentId = resolvedContentId,
contentType = resolvedContentType,
season = requestedSeason,
episode = requestedEpisode,
title = episodeTitle
)
cacheMutex.withLock {
reverseMappingCache[reverseKey]?.let { return it }
}
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
val mapped = reverseRemapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes
) ?: return null
cacheMutex.withLock {
reverseMappingCache[reverseKey] = mapped
}
return mapped
}
internal suspend fun getCachedEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
return cacheMutex.withLock { mappingCache[key] }
}
internal suspend fun resolveEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
cacheMutex.withLock {
mappingCache[key]?.let { return it }
}
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
val mapped = remapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = videoId,
requestedTitle = null,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes
) ?: return null
cacheMutex.withLock {
mappingCache[key] = mapped
}
return mapped
}
private suspend fun getAddonEpisodes(
contentId: String,
contentType: String
): List<EpisodeMappingEntry> {
val cacheKey = addonEpisodesCacheKey(contentId, contentType)
cacheMutex.withLock {
addonEpisodesCache[cacheKey]?.let { return it }
}
val meta = fetchSeriesMeta(contentId, contentType) ?: return emptyList()
val addonEpisodes = meta.videos.toEpisodeMappingEntries()
if (addonEpisodes.isEmpty()) return emptyList()
cacheMutex.withLock {
addonEpisodesCache[cacheKey] = addonEpisodes
}
return addonEpisodes
}
private suspend fun getTraktEpisodes(showLookupId: String): List<EpisodeMappingEntry> {
cacheMutex.withLock {
traktEpisodesCache[showLookupId]?.let { return it }
}
val seasonsResponse = traktAuthService.executeAuthorizedRequest { authHeader ->
traktApi.getShowSeasons(
authorization = authHeader,
id = showLookupId,
extended = "episodes"
)
} ?: return emptyList()
if (!seasonsResponse.isSuccessful) {
Log.w(
TAG,
"getTraktEpisodes: seasons request failed code=${seasonsResponse.code()} id=$showLookupId"
)
return emptyList()
}
val traktEpisodes = seasonsResponse.body()
.orEmpty()
.asSequence()
.filter { (it.number ?: 0) > 0 }
.sortedBy { it.number }
.flatMap { seasonDto ->
seasonDto.episodes.orEmpty().asSequence()
.mapNotNull { episodeDto ->
val seasonNumber = episodeDto.season ?: seasonDto.number ?: return@mapNotNull null
val episodeNumber = episodeDto.number ?: return@mapNotNull null
EpisodeMappingEntry(
season = seasonNumber,
episode = episodeNumber,
title = episodeDto.title
)
}
}
.toList()
if (traktEpisodes.isNotEmpty()) {
cacheMutex.withLock {
traktEpisodesCache[showLookupId] = traktEpisodes
}
}
return traktEpisodes
}
private fun cacheKey(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?
): String? {
val resolvedContentId = contentId?.trim()?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val resolvedSeason = season ?: return null
val resolvedEpisode = episode ?: return null
val resolvedVideoId = videoId?.trim().orEmpty()
return "$resolvedContentType|$resolvedContentId|$resolvedVideoId|$resolvedSeason|$resolvedEpisode"
}
private fun reverseCacheKey(
contentId: String,
contentType: String,
season: Int,
episode: Int,
title: String?
): String {
val normalizedTitle = title?.trim()?.lowercase().orEmpty()
return "reverse|${contentType.trim().lowercase()}|${contentId.trim()}|$season|$episode|$normalizedTitle"
}
private fun addonEpisodesCacheKey(contentId: String, contentType: String): String {
return "${contentType.trim().lowercase()}|${contentId.trim()}"
}
private fun resolveShowLookupId(contentId: String?, videoId: String?): String? {
val contentIds = toTraktIds(parseContentIds(contentId))
if (contentIds.hasAnyId()) {
return when {
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
contentIds.trakt != null -> contentIds.trakt.toString()
!contentIds.slug.isNullOrBlank() -> contentIds.slug
else -> null
}
}
val videoIds = toTraktIds(parseContentIds(videoId))
return when {
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
videoIds.trakt != null -> videoIds.trakt.toString()
!videoIds.slug.isNullOrBlank() -> videoIds.slug
else -> null
}
}
private suspend fun fetchSeriesMeta(contentId: String, contentType: String): Meta? {
val typeCandidates = buildList {
val normalized = contentType.lowercase()
if (normalized.isNotBlank()) add(normalized)
if (normalized in listOf("series", "tv")) {
add("series")
add("tv")
}
}.distinct()
if (typeCandidates.isEmpty()) return null
val idCandidates = buildList {
add(contentId)
if (contentId.startsWith("tmdb:")) add(contentId.substringAfter(':'))
if (contentId.startsWith("trakt:")) add(contentId.substringAfter(':'))
}.distinct()
for (type in typeCandidates) {
for (candidateId in idCandidates) {
val result = withTimeoutOrNull(3500) {
metaRepository.getMetaFromAllAddons(type = type, id = candidateId)
.first { it !is NetworkResult.Loading }
} ?: continue
val meta = (result as? NetworkResult.Success)?.data ?: continue
if (meta.videos.any { it.season != null && it.episode != null }) {
return meta
}
}
}
return null
}
private fun List<Video>.toEpisodeMappingEntries(): List<EpisodeMappingEntry> {
return asSequence()
.mapNotNull { video ->
val season = video.season ?: return@mapNotNull null
val episode = video.episode ?: return@mapNotNull null
if (season <= 0) return@mapNotNull null
EpisodeMappingEntry(
season = season,
episode = episode,
title = video.title,
videoId = video.id.takeIf { it.isNotBlank() }
)
}
.distinctBy { it.videoId ?: "${it.season}:${it.episode}" }
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
.toList()
}
}

View file

@ -75,7 +75,7 @@ class TraktLibraryService @Inject constructor(
private var lastRefreshMs: Long = 0L
private val cacheTtlMs = 60_000L
private val metadataHydrationLimit = 250
private val metadataHydrationLimit = 500
private val listFetchConcurrency = 3
private val metadataFetchSemaphore = Semaphore(5)
@ -383,7 +383,10 @@ class TraktLibraryService @Inject constructor(
releaseInfo = item.releaseInfo ?: item.year?.toString(),
imdbRating = item.imdbRating,
genres = item.genres,
addonBaseUrl = item.addonBaseUrl
addonBaseUrl = item.addonBaseUrl,
imdbId = item.imdbId,
tmdbId = item.tmdbId,
traktId = item.traktId
)).copy(
listedAt = System.currentTimeMillis(),
listKeys = existing?.listKeys.orEmpty() + listKey
@ -411,8 +414,11 @@ class TraktLibraryService @Inject constructor(
val membership = mutableMapOf<String, MutableSet<String>>()
rawEntriesByList.forEach { (listKey, entries) ->
entries.forEach { entry ->
membership.getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() }
.add(listKey)
val lists = membership.getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() }
lists.add(listKey)
for (alias in allContentKeys(entry)) {
membership.getOrPut(alias) { mutableSetOf() }.add(listKey)
}
}
}
@ -470,8 +476,11 @@ class TraktLibraryService @Inject constructor(
val membership = mutableMapOf<String, MutableSet<String>>()
rawEntriesByList.forEach { (listKey, entries) ->
entries.forEach { entry ->
val contentKey = contentKey(entry.id, entry.type)
membership.getOrPut(contentKey) { mutableSetOf() }.add(listKey)
val primaryKey = contentKey(entry.id, entry.type)
membership.getOrPut(primaryKey) { mutableSetOf() }.add(listKey)
for (alias in allContentKeys(entry)) {
membership.getOrPut(alias) { mutableSetOf() }.add(listKey)
}
}
}
@ -662,7 +671,10 @@ class TraktLibraryService @Inject constructor(
addonBaseUrl = null,
listKeys = setOf(listKey),
listedAt = parseIsoToMillis(item.listedAt),
traktRank = item.rank
traktRank = item.rank,
imdbId = ids?.imdb?.takeIf { it.isNotBlank() },
tmdbId = ids?.tmdb,
traktId = ids?.trakt
)
}
@ -805,6 +817,15 @@ class TraktLibraryService @Inject constructor(
return "$normalizedType:$stableId"
}
private fun allContentKeys(entry: LibraryEntry): Set<String> {
val type = normalizeItemType(entry.type)
val keys = mutableSetOf(contentKey(entry.id, entry.type))
entry.imdbId?.takeIf { it.isNotBlank() }?.let { keys.add("$type:$it") }
entry.tmdbId?.let { keys.add("$type:tmdb:$it") }
entry.traktId?.let { keys.add("$type:trakt:$it") }
return keys
}
private fun normalizeItemType(itemType: String): String {
return when (itemType.lowercase()) {
"movie" -> "movie"

View file

@ -0,0 +1,348 @@
package com.nuvio.tv.data.repository
import com.nuvio.tv.data.remote.api.TraktApi
import com.nuvio.tv.data.remote.dto.trakt.TraktIdsDto
import com.nuvio.tv.data.remote.dto.trakt.TraktImagesDto
import com.nuvio.tv.data.remote.dto.trakt.TraktMovieDto
import com.nuvio.tv.data.remote.dto.trakt.TraktSearchResultDto
import com.nuvio.tv.data.remote.dto.trakt.TraktShowDto
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.Meta
import com.nuvio.tv.domain.model.MetaPreview
import com.nuvio.tv.domain.model.PosterShape
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
private const val RELATED_LIMIT = 20
private const val RELATED_CACHE_TTL_MS = 10 * 60_000L
internal enum class TraktRelatedType(val apiValue: String) {
MOVIE("movie"),
SHOW("show")
}
internal data class ResolvedRelatedTarget(
val type: TraktRelatedType,
val pathId: String
)
@Singleton
class TraktRelatedService @Inject constructor(
private val traktApi: TraktApi,
private val traktAuthService: TraktAuthService
) {
private data class TimedCache(
val items: List<MetaPreview>,
val updatedAtMs: Long
)
private val cache = ConcurrentHashMap<String, TimedCache>()
suspend fun getRelated(
meta: Meta,
fallbackItemId: String? = null,
fallbackItemType: String? = null,
forceRefresh: Boolean = false
): List<MetaPreview> {
val target = resolveRelatedTarget(meta, fallbackItemId, fallbackItemType) ?: return emptyList()
val cacheKey = buildString {
append(target.type.apiValue)
append("|")
append(target.pathId)
}
if (!forceRefresh) {
cache[cacheKey]?.let { cached ->
if (System.currentTimeMillis() - cached.updatedAtMs <= RELATED_CACHE_TTL_MS) {
return cached.items
}
}
} else {
cache.remove(cacheKey)
}
val items = when (target.type) {
TraktRelatedType.MOVIE -> {
val response = traktAuthService.executeAuthorizedRequest { authHeader ->
traktApi.getMovieRelated(
authorization = authHeader,
id = target.pathId,
limit = RELATED_LIMIT
)
} ?: throw IllegalStateException("Trakt related request failed")
when {
response.code() == 404 -> emptyList()
!response.isSuccessful -> throw IllegalStateException("Failed to load Trakt related titles (${response.code()})")
else -> response.body().orEmpty()
.mapNotNull { dto ->
dto.toMetaPreview(
defaultType = ContentType.MOVIE,
rawType = "movie"
)
}
}
}
TraktRelatedType.SHOW -> {
val response = traktAuthService.executeAuthorizedRequest { authHeader ->
traktApi.getShowRelated(
authorization = authHeader,
id = target.pathId,
limit = RELATED_LIMIT
)
} ?: throw IllegalStateException("Trakt related request failed")
when {
response.code() == 404 -> emptyList()
!response.isSuccessful -> throw IllegalStateException("Failed to load Trakt related titles (${response.code()})")
else -> response.body().orEmpty()
.mapNotNull { dto ->
dto.toMetaPreview(
defaultType = ContentType.SERIES,
rawType = "series"
)
}
}
}
}
val distinctItems = items.distinctBy { "${it.apiType}:${it.id}" }
cache[cacheKey] = TimedCache(items = distinctItems, updatedAtMs = System.currentTimeMillis())
return distinctItems
}
private suspend fun resolveRelatedTarget(
meta: Meta,
fallbackItemId: String?,
fallbackItemType: String?
): ResolvedRelatedTarget? {
val type = resolveRelatedType(meta = meta, fallbackItemType = fallbackItemType) ?: return null
val directPathId = resolveDirectPathId(meta = meta, fallbackItemId = fallbackItemId)
if (!directPathId.isNullOrBlank()) {
return ResolvedRelatedTarget(type = type, pathId = directPathId)
}
val tmdbId = resolveTmdbCandidate(meta = meta, fallbackItemId = fallbackItemId) ?: return null
val searchResponse = traktAuthService.executeAuthorizedRequest { authHeader ->
traktApi.searchById(
authorization = authHeader,
idType = "tmdb",
id = tmdbId.toString(),
type = type.apiValue
)
} ?: throw IllegalStateException("Trakt TMDB search request failed")
if (!searchResponse.isSuccessful) {
if (searchResponse.code() == 404) return null
throw IllegalStateException("Failed to resolve Trakt id (${searchResponse.code()})")
}
val resolvedPathId = searchResponse.body()
.orEmpty()
.firstOrNull { it.type.equals(type.apiValue, ignoreCase = true) }
?.toTraktPathId(type)
return resolvedPathId?.let { ResolvedRelatedTarget(type = type, pathId = it) }
}
private fun resolveRelatedType(meta: Meta, fallbackItemType: String?): TraktRelatedType? {
val normalizedType = listOf(meta.apiType, meta.rawType, fallbackItemType)
.firstNotNullOfOrNull { value -> value?.trim()?.lowercase()?.takeIf { it.isNotBlank() } }
return when (meta.type) {
ContentType.MOVIE -> TraktRelatedType.MOVIE
ContentType.SERIES, ContentType.TV -> TraktRelatedType.SHOW
else -> when (normalizedType) {
"movie" -> TraktRelatedType.MOVIE
"series", "show", "tv" -> TraktRelatedType.SHOW
else -> null
}
}
}
private fun resolveDirectPathId(meta: Meta, fallbackItemId: String?): String? {
meta.imdbId?.takeIf { it.isNotBlank() }?.let { return it }
val metaIds = parseContentIds(meta.id)
metaIds.imdb?.takeIf { it.isNotBlank() }?.let { return it }
metaIds.trakt?.let { return it.toString() }
meta.slug?.takeIf { it.isNotBlank() }?.let { return it }
val fallbackIds = parseContentIds(fallbackItemId)
fallbackIds.imdb?.takeIf { it.isNotBlank() }?.let { return it }
fallbackIds.trakt?.let { return it.toString() }
return null
}
private fun resolveTmdbCandidate(meta: Meta, fallbackItemId: String?): Int? {
val metaIds = parseContentIds(meta.id)
if (metaIds.tmdb != null) return metaIds.tmdb
return parseContentIds(fallbackItemId).tmdb
}
}
private fun TraktMovieDto.toMetaPreview(
defaultType: ContentType,
rawType: String
): MetaPreview? {
return toMetaPreviewInternal(
title = title ?: originalTitle,
year = year,
ids = ids,
overview = overview,
releaseDate = released,
runtimeMinutes = runtime,
ratingValue = rating,
genresList = genres,
certificationValue = certification,
languagesList = languages,
countryValue = country,
statusValue = status,
originalTitleValue = originalTitle,
imagesValue = images,
defaultType = defaultType,
rawType = rawType
)
}
private fun TraktShowDto.toMetaPreview(
defaultType: ContentType,
rawType: String
): MetaPreview? {
return toMetaPreviewInternal(
title = title ?: originalTitle,
year = year,
ids = ids,
overview = overview,
releaseDate = firstAired,
runtimeMinutes = runtime,
ratingValue = rating,
genresList = genres,
certificationValue = certification,
languagesList = languages,
countryValue = country,
statusValue = status,
originalTitleValue = originalTitle,
imagesValue = images,
defaultType = defaultType,
rawType = rawType
)
}
private fun toMetaPreviewInternal(
title: String?,
year: Int?,
ids: TraktIdsDto?,
overview: String?,
releaseDate: String?,
runtimeMinutes: Int?,
ratingValue: Double?,
genresList: List<String>?,
certificationValue: String?,
languagesList: List<String>?,
countryValue: String?,
statusValue: String?,
originalTitleValue: String?,
imagesValue: TraktImagesDto?,
defaultType: ContentType,
rawType: String
): MetaPreview? {
val normalizedTitle = title?.trim()?.takeIf { it.isNotBlank() } ?: return null
val fallbackId = when {
ids?.trakt != null -> "trakt:${ids.trakt}"
!ids?.slug.isNullOrBlank() -> ids.slug
else -> null
}
val contentId = normalizeContentId(ids, fallback = fallbackId)
if (contentId.isBlank()) return null
val poster = imagesValue.bestLandscapeImage()
val background = imagesValue.bestBackdropImage()
val logo = imagesValue.bestLogoImage()
val releaseInfo = year?.toString() ?: extractYear(releaseDate)?.toString()
return MetaPreview(
id = contentId,
type = defaultType,
rawType = rawType,
name = normalizedTitle,
poster = poster,
posterShape = PosterShape.LANDSCAPE,
background = background,
logo = logo,
description = overview?.trim()?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo,
imdbRating = ratingValue?.toFloat(),
genres = genresList.orEmpty(),
runtime = runtimeMinutes?.takeIf { it > 0 }?.let { "$it min" },
status = statusValue?.trim()?.takeIf { it.isNotBlank() },
ageRating = certificationValue?.trim()?.takeIf { it.isNotBlank() },
language = languagesList.orEmpty().firstOrNull()?.takeIf { it.isNotBlank() },
released = releaseDate?.trim()?.takeIf { it.isNotBlank() },
country = countryValue?.trim()?.takeIf { it.isNotBlank() },
imdbId = ids?.imdb?.takeIf { it.isNotBlank() },
slug = ids?.slug?.takeIf { it.isNotBlank() },
landscapePoster = background,
rawPosterUrl = poster
)
}
private fun TraktImagesDto?.bestLandscapeImage(): String? {
if (this == null) return null
return thumb.firstImageUrl()
?: fanart.firstImageUrl()
?: banner.firstImageUrl()
?: poster.firstImageUrl()
}
private fun TraktImagesDto?.bestBackdropImage(): String? {
if (this == null) return null
return fanart.firstImageUrl()
?: banner.firstImageUrl()
?: thumb.firstImageUrl()
?: poster.firstImageUrl()
}
private fun TraktImagesDto?.bestLogoImage(): String? {
if (this == null) return null
return logo.firstImageUrl() ?: clearart.firstImageUrl()
}
private fun List<String>?.firstImageUrl(): String? {
return this.orEmpty()
.firstOrNull { !it.isNullOrBlank() }
?.toHttpsImageUrl()
}
private fun String.toHttpsImageUrl(): String {
val normalized = trim()
return when {
normalized.startsWith("https://", ignoreCase = true) -> normalized
normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.removePrefix("http://")}"
normalized.startsWith("//") -> "https:$normalized"
else -> "https://$normalized"
}
}
internal fun TraktSearchResultDto.toTraktPathId(expectedType: TraktRelatedType): String? {
val ids = when (expectedType) {
TraktRelatedType.MOVIE -> movie?.ids
TraktRelatedType.SHOW -> show?.ids
}
return ids.toBestRelatedPathId()
}
internal fun TraktIdsDto?.toBestRelatedPathId(): String? {
if (this == null) return null
return when {
!imdb.isNullOrBlank() -> imdb
trakt != null -> trakt.toString()
!slug.isNullOrBlank() -> slug
else -> null
}
}

View file

@ -6,6 +6,8 @@ import com.nuvio.tv.core.sync.WatchProgressSyncService
import com.nuvio.tv.core.sync.WatchedItemsSyncService
import android.util.Log
import com.nuvio.tv.data.local.TraktAuthDataStore
import com.nuvio.tv.data.local.TraktSettingsDataStore
import com.nuvio.tv.data.local.WatchProgressSource
import com.nuvio.tv.data.local.WatchProgressPreferences
import com.nuvio.tv.data.local.WatchedItemsPreferences
import com.nuvio.tv.domain.model.WatchProgress
@ -19,11 +21,15 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
@ -39,6 +45,7 @@ import javax.inject.Singleton
class WatchProgressRepositoryImpl @Inject constructor(
private val watchProgressPreferences: WatchProgressPreferences,
private val traktAuthDataStore: TraktAuthDataStore,
private val traktSettingsDataStore: TraktSettingsDataStore,
private val traktProgressService: TraktProgressService,
private val watchProgressSyncService: WatchProgressSyncService,
private val watchedItemsPreferences: WatchedItemsPreferences,
@ -48,6 +55,7 @@ class WatchProgressRepositoryImpl @Inject constructor(
) : WatchProgressRepository {
companion object {
private const val TAG = "WatchProgressRepo"
private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3 * 60_000L
}
private data class EpisodeMetadata(
@ -71,9 +79,13 @@ class WatchProgressRepositoryImpl @Inject constructor(
var hasCompletedInitialWatchedItemsPull = false
private val metadataState = MutableStateFlow<Map<String, ContentMetadata>>(emptyMap())
private val optimisticContinueWatchingUpdates = MutableSharedFlow<WatchProgress>(
replay = 1,
extraBufferCapacity = 16
)
private val metadataMutex = Mutex()
private val inFlightMetadataKeys = mutableSetOf<String>()
private val metadataHydrationLimit = 250
private val metadataHydrationLimit = 30
private fun triggerRemoteSync() {
if (isSyncingFromRemote) return
@ -161,7 +173,7 @@ class WatchProgressRepositoryImpl @Inject constructor(
for (type in typeCandidates) {
for (candidateId in idCandidates) {
val result = withTimeoutOrNull(3500) {
metaRepository.getMetaFromAllAddons(type = type, id = candidateId)
metaRepository.getMetaFromPrimaryAddon(type = type, id = candidateId)
.first { it !is NetworkResult.Loading }
} ?: continue
@ -213,33 +225,35 @@ class WatchProgressRepositoryImpl @Inject constructor(
)
}
override val allProgress: Flow<List<WatchProgress>>
get() = traktAuthDataStore.isEffectivelyAuthenticated
private fun useTraktProgressFlow(): Flow<Boolean> {
return combine(
traktAuthDataStore.isEffectivelyAuthenticated,
traktSettingsDataStore.watchProgressSource
) { isEffectivelyAuthenticated, source ->
isEffectivelyAuthenticated && source == WatchProgressSource.TRAKT
}.distinctUntilChanged()
}
private suspend fun shouldUseTraktProgress(): Boolean = useTraktProgressFlow().first()
private suspend fun hasEffectiveTraktConnection(): Boolean =
traktAuthDataStore.isEffectivelyAuthenticated.first()
private fun traktAllProgressFlow(): Flow<List<WatchProgress>> {
return traktProgressService.observeAllProgress()
.onStart {
emit(emptyList())
}
.distinctUntilChanged()
.flatMapLatest { isAuthenticated ->
if (isAuthenticated) {
combine(
traktProgressService.observeAllProgress()
.onStart {
// Emit local-cache-backed continue watching immediately on app start
// while the first Trakt snapshot is still loading.
emit(emptyList())
},
watchProgressPreferences.allRawProgress,
metadataState
) { remoteItems, localItems, metadataMap ->
val merged = mergeProgressLists(remoteItems, localItems)
hydrateMetadata(merged)
merged.map { enrichWithMetadata(it, metadataMap) }
}
}
override val allProgress: Flow<List<WatchProgress>>
get() = useTraktProgressFlow()
.flatMapLatest { useTraktProgress ->
if (useTraktProgress) {
traktAllProgressFlow()
} else {
combine(
watchProgressPreferences.allProgress,
metadataState
) { items, metadataMap ->
hydrateMetadata(items)
items.map { enrichWithMetadata(it, metadataMap) }
}
watchProgressPreferences.allProgress
}
}
@ -247,11 +261,10 @@ class WatchProgressRepositoryImpl @Inject constructor(
get() = allProgress.map { list -> list.filter { it.isInProgress() } }
override fun getProgress(contentId: String): Flow<WatchProgress?> {
return traktAuthDataStore.isEffectivelyAuthenticated
.distinctUntilChanged()
.flatMapLatest { isAuthenticated ->
if (isAuthenticated) {
allProgress.map { items ->
return useTraktProgressFlow()
.flatMapLatest { useTraktProgress ->
if (useTraktProgress) {
traktProgressService.observeAllProgress().map { items ->
items
.filter { it.contentId == contentId }
.maxByOrNull { it.lastWatched }
@ -263,11 +276,10 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
override fun getEpisodeProgress(contentId: String, season: Int, episode: Int): Flow<WatchProgress?> {
return traktAuthDataStore.isEffectivelyAuthenticated
.distinctUntilChanged()
.flatMapLatest { isAuthenticated ->
if (isAuthenticated) {
allProgress.map { items ->
return useTraktProgressFlow()
.flatMapLatest { useTraktProgress ->
if (useTraktProgress) {
traktProgressService.observeAllProgress().map { items ->
items.firstOrNull {
it.contentId == contentId && it.season == season && it.episode == episode
}
@ -279,21 +291,21 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
override fun getAllEpisodeProgress(contentId: String): Flow<Map<Pair<Int, Int>, WatchProgress>> {
return traktAuthDataStore.isEffectivelyAuthenticated
.distinctUntilChanged()
.flatMapLatest { isAuthenticated ->
if (isAuthenticated) {
return useTraktProgressFlow()
.flatMapLatest { useTraktProgress ->
if (useTraktProgress) {
combine(
traktProgressService.observeEpisodeProgress(contentId),
traktProgressService.observeEpisodeProgress(contentId)
.onStart { emit(emptyMap()) },
allProgress.map { items ->
items.filter { it.contentId == contentId && it.season != null && it.episode != null }
}
) { remoteMap, liveEpisodes ->
val merged = remoteMap.toMutableMap()
liveEpisodes.forEach { episodeProgress ->
val season = episodeProgress.season ?: return@forEach
val episode = episodeProgress.episode ?: return@forEach
merged[season to episode] = episodeProgress
val seasonNum = episodeProgress.season ?: return@forEach
val episodeNum = episodeProgress.episode ?: return@forEach
merged[seasonNum to episodeNum] = episodeProgress
}
merged
}.distinctUntilChanged()
@ -303,11 +315,178 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
}
override fun isWatched(contentId: String, season: Int?, episode: Int?): Flow<Boolean> {
return traktAuthDataStore.isEffectivelyAuthenticated
override fun getAiredEpisodeOrder(contentId: String): Flow<List<Pair<Int, Int>>> {
return useTraktProgressFlow()
.flatMapLatest { useTraktProgress ->
if (useTraktProgress) {
traktProgressService.observeAiredEpisodes(contentId)
} else {
flowOf(emptyList())
}
}
.distinctUntilChanged()
.flatMapLatest { isAuthenticated ->
if (!isAuthenticated) {
}
override fun observeNextUpSeeds(): Flow<List<WatchProgress>> {
return useTraktProgressFlow()
.flatMapLatest { useTraktProgress ->
if (useTraktProgress) {
combine(
traktProgressService.observeWatchedShowSeeds(),
traktProgressService.observeAllProgress()
.map { items ->
val nowMs = System.currentTimeMillis()
items.filter { progress ->
isOptimisticNextUpSeedCandidate(progress, nowMs)
}
}
.onStart { emit(emptyList()) }
) { canonicalSeeds, optimisticSeeds ->
mergeNextUpSeeds(canonicalSeeds, optimisticSeeds)
}
} else {
watchProgressPreferences.allProgress.map { items ->
items.filter { progress ->
progress.isCompleted() &&
progress.contentType.equals("series", ignoreCase = true) &&
progress.season != null &&
progress.episode != null &&
progress.season != 0 &&
!isMalformedNextUpSeedContentId(progress.contentId)
}
}
}
}
.distinctUntilChanged()
}
private fun isOptimisticNextUpSeedCandidate(
progress: WatchProgress,
nowMs: Long
): Boolean {
if (!progress.contentType.equals("series", ignoreCase = true)) return false
if (!progress.isCompleted()) return false
if (progress.source != WatchProgress.SOURCE_TRAKT_PLAYBACK) return false
if (progress.season == null || progress.episode == null || progress.season == 0) return false
val ageMs = nowMs - progress.lastWatched
return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS
}
private fun mergeNextUpSeeds(
canonicalSeeds: List<WatchProgress>,
optimisticSeeds: List<WatchProgress>
): List<WatchProgress> {
val merged = linkedMapOf<String, WatchProgress>()
canonicalSeeds.forEach { seed ->
merged[nextUpSeedKey(seed)] = seed
}
optimisticSeeds.forEach { seed ->
val key = nextUpSeedKey(seed)
val existing = merged[key]
if (existing == null || shouldReplaceNextUpSeed(existing, seed)) {
merged[key] = seed
}
}
return merged.values.sortedByDescending { it.lastWatched }
}
private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
val trimmed = contentId?.trim().orEmpty()
if (trimmed.isEmpty()) return true
val lowered = trimmed.lowercase()
return lowered == "tmdb" ||
lowered == "imdb" ||
lowered == "trakt" ||
lowered == "tmdb:" ||
lowered == "imdb:" ||
lowered == "trakt:"
}
private fun nextUpSeedKey(progress: WatchProgress): String {
return progress.traktShowId?.let { "trakt_show:$it" }
?: progress.contentId.trim()
}
private fun shouldReplaceNextUpSeed(
existing: WatchProgress,
candidate: WatchProgress
): Boolean {
val candidateSeason = candidate.season ?: -1
val candidateEpisode = candidate.episode ?: -1
val existingSeason = existing.season ?: -1
val existingEpisode = existing.episode ?: -1
return candidateSeason > existingSeason ||
(
candidateSeason == existingSeason &&
(
candidateEpisode > existingEpisode ||
(
candidateEpisode == existingEpisode &&
candidate.lastWatched >= existing.lastWatched
)
)
)
}
override fun observeOptimisticContinueWatchingUpdates(): Flow<WatchProgress> {
return optimisticContinueWatchingUpdates
}
@OptIn(FlowPreview::class)
override fun observeWatchedMovieIds(): Flow<Set<String>> {
return useTraktProgressFlow()
.flatMapLatest { useTraktProgress ->
if (useTraktProgress) {
traktProgressService.observeAllWatchedMovieIds()
} else {
combine(
watchProgressPreferences.allProgress,
watchedItemsPreferences.allItems
) { progressList, watchedItems ->
val completedIds = mutableSetOf<String>()
val replayingIds = mutableSetOf<String>()
for (progress in progressList) {
if (progress.isCompleted()) {
completedIds.add(progress.contentId)
} else if (progress.position > 0L ||
progress.progressPercent?.let { it > 0f } == true
) {
replayingIds.add(progress.contentId)
}
}
val watchedItemIds = watchedItems
.filter { it.season == null && it.episode == null }
.map { it.contentId }
.toSet()
(completedIds + watchedItemIds) - replayingIds
}.debounce(500)
}
}
.distinctUntilChanged()
}
/**
* Returns per-show watched episodes from the active source.
* For Trakt: from /sync/watched/shows response.
* For Nuvio sync: from watchedItemsPreferences.
*/
override suspend fun getWatchedShowEpisodes(): Map<String, Set<Pair<Int, Int>>> {
return if (shouldUseTraktProgress()) {
traktProgressService.getWatchedShowEpisodes()
} else {
watchedItemsPreferences.allItems.first()
.filter { it.season != null && it.episode != null }
.groupBy { it.contentId }
.mapValues { (_, items) ->
items.map { it.season!! to it.episode!! }.toSet()
}
}
}
override fun isWatched(contentId: String, videoId: String?, season: Int?, episode: Int?): Flow<Boolean> {
return useTraktProgressFlow()
.flatMapLatest { useTraktProgress ->
if (!useTraktProgress) {
val progressFlow = if (season != null && episode != null) {
watchProgressPreferences.getEpisodeProgress(contentId, season, episode)
} else {
@ -337,20 +516,24 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
.distinctUntilChanged()
} else {
traktProgressService.observeMovieWatched(contentId)
traktProgressService.observeMovieWatched(contentId, videoId)
}
}
}
override suspend fun saveProgress(progress: WatchProgress, syncRemote: Boolean) {
if (traktAuthDataStore.isEffectivelyAuthenticated.first()) {
// Clear any CW dismiss keys for this series so it reappears in Continue Watching.
if (progress.contentType.equals("series", ignoreCase = true) ||
progress.contentType.equals("tv", ignoreCase = true)) {
traktSettingsDataStore.removeDismissedNextUpKeysForContent(progress.contentId)
}
if (shouldUseTraktProgress()) {
traktProgressService.applyOptimisticProgress(progress)
watchProgressPreferences.saveProgress(progress)
return
}
watchProgressPreferences.saveProgress(progress)
if (syncRemote && authManager.isAuthenticated) {
syncScope.launch {
watchProgressSyncService.pushSingleToRemote(progressKey(progress), progress)
@ -375,20 +558,59 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
}
override suspend fun removeProgress(contentId: String, season: Int?, episode: Int?) {
val isAuthenticated = traktAuthDataStore.isEffectivelyAuthenticated.first()
Log.d(
TAG,
"removeProgress called contentId=$contentId season=$season episode=$episode authenticated=$isAuthenticated"
)
if (isAuthenticated) {
traktProgressService.applyOptimisticRemoval(contentId, season, episode)
traktProgressService.removeProgress(contentId, season, episode)
watchProgressPreferences.removeProgress(contentId, season, episode)
override suspend fun saveProgressBatch(progressList: List<WatchProgress>, syncRemote: Boolean) {
if (progressList.isEmpty()) return
if (shouldUseTraktProgress()) {
progressList.forEach { progress ->
traktProgressService.applyOptimisticProgress(progress)
}
watchProgressPreferences.saveProgressBatch(progressList)
return
}
val remoteDeleteKeys = resolveRemoteDeleteKeys(contentId, season, episode)
watchProgressPreferences.saveProgressBatch(progressList)
if (syncRemote && authManager.isAuthenticated) {
triggerRemoteSync()
}
var completedSaved = false
progressList
.filter { it.isCompleted() }
.forEach { progress ->
watchedItemsPreferences.markAsWatched(
WatchedItem(
contentId = progress.contentId,
contentType = progress.contentType,
title = progress.name,
season = progress.season,
episode = progress.episode,
watchedAt = System.currentTimeMillis()
)
)
completedSaved = true
}
if (completedSaved) {
triggerWatchedItemsSync()
}
}
override suspend fun removeProgress(contentId: String, season: Int?, episode: Int?) {
val useTraktProgress = shouldUseTraktProgress()
val hasEffectiveTraktConnection = hasEffectiveTraktConnection()
val remoteDeleteKeys = if (!useTraktProgress) {
resolveRemoteDeleteKeys(contentId, season, episode)
} else {
emptyList()
}
if (hasEffectiveTraktConnection) {
traktProgressService.applyOptimisticRemoval(contentId, season, episode)
traktProgressService.removeProgress(contentId, season, episode)
}
watchProgressPreferences.removeProgress(contentId, season, episode)
if (useTraktProgress) {
return
}
if (authManager.isAuthenticated && remoteDeleteKeys.isNotEmpty()) {
watchProgressSyncService.deleteFromRemote(remoteDeleteKeys)
.onFailure { error ->
@ -398,27 +620,46 @@ class WatchProgressRepositoryImpl @Inject constructor(
triggerRemoteSync()
}
override suspend fun removeFromHistory(contentId: String, season: Int?, episode: Int?) {
if (traktAuthDataStore.isEffectivelyAuthenticated.first()) {
traktProgressService.removeFromHistory(contentId, season, episode)
watchProgressPreferences.removeProgress(contentId, season, episode)
return
override suspend fun removeFromHistory(contentId: String, videoId: String?, season: Int?, episode: Int?) {
val useTraktProgress = shouldUseTraktProgress()
val remoteDeleteKeys = if (!useTraktProgress) {
resolveRemoteDeleteKeys(contentId, season, episode)
} else {
emptyList()
}
if (hasEffectiveTraktConnection()) {
traktProgressService.removeFromHistory(contentId, videoId, season, episode)
}
val remoteDeleteKeys = resolveRemoteDeleteKeys(contentId, season, episode)
watchProgressPreferences.removeProgress(contentId, season, episode)
watchedItemsPreferences.unmarkAsWatched(contentId, season, episode)
if (useTraktProgress) {
return
}
if (authManager.isAuthenticated && remoteDeleteKeys.isNotEmpty()) {
watchProgressSyncService.deleteFromRemote(remoteDeleteKeys)
.onFailure { error ->
Log.w(TAG, "removeFromHistory remote delete failed; relying on push sync", error)
}
}
if (authManager.isAuthenticated && !useTraktProgress) {
watchedItemsSyncService.deleteFromRemote(contentId, season, episode)
.onFailure { error ->
Log.w(TAG, "removeFromHistory watched item remote delete failed", error)
}
}
triggerRemoteSync()
triggerWatchedItemsSync()
}
override suspend fun markAsCompleted(progress: WatchProgress) {
if (traktAuthDataStore.isEffectivelyAuthenticated.first()) {
// Clear any CW dismiss keys for this series so it reappears in Continue Watching.
if (progress.contentType.equals("series", ignoreCase = true) ||
progress.contentType.equals("tv", ignoreCase = true)) {
traktSettingsDataStore.removeDismissedNextUpKeysForContent(progress.contentId)
}
val useTraktProgress = shouldUseTraktProgress()
val hasEffectiveTraktConnection = hasEffectiveTraktConnection()
if (useTraktProgress && hasEffectiveTraktConnection) {
val now = System.currentTimeMillis()
val duration = progress.duration.takeIf { it > 0L } ?: 1L
val completed = progress.copy(
@ -427,6 +668,7 @@ class WatchProgressRepositoryImpl @Inject constructor(
progressPercent = 100f,
lastWatched = now
)
optimisticContinueWatchingUpdates.tryEmit(completed)
traktProgressService.applyOptimisticProgress(completed)
runCatching {
traktProgressService.markAsWatched(
@ -442,6 +684,17 @@ class WatchProgressRepositoryImpl @Inject constructor(
)
throw it
}
watchProgressPreferences.markAsCompleted(progress)
watchedItemsPreferences.markAsWatched(
WatchedItem(
contentId = progress.contentId,
contentType = progress.contentType,
title = progress.name,
season = progress.season,
episode = progress.episode,
watchedAt = System.currentTimeMillis()
)
)
return
}
watchProgressPreferences.markAsCompleted(progress)
@ -455,12 +708,32 @@ class WatchProgressRepositoryImpl @Inject constructor(
watchedAt = System.currentTimeMillis()
)
)
if (hasEffectiveTraktConnection) {
val now = System.currentTimeMillis()
val duration = progress.duration.takeIf { it > 0L } ?: 1L
val completed = progress.copy(
position = duration,
duration = duration,
progressPercent = 100f,
lastWatched = now
)
optimisticContinueWatchingUpdates.tryEmit(completed)
runCatching {
traktProgressService.markAsWatched(
progress = completed,
title = completed.name.takeIf { it.isNotBlank() },
year = null
)
}.onFailure { error ->
Log.w(TAG, "Failed to mirror completed state to Trakt", error)
}
}
triggerRemoteSync()
triggerWatchedItemsSync()
}
override suspend fun clearAll() {
if (traktAuthDataStore.isEffectivelyAuthenticated.first()) {
if (shouldUseTraktProgress()) {
traktProgressService.clearOptimistic()
watchProgressPreferences.clearAll()
return
@ -481,59 +754,22 @@ class WatchProgressRepositoryImpl @Inject constructor(
season: Int?,
episode: Int?
): List<String> {
val rawEntries = watchProgressPreferences.getAllRawEntries()
val keys = if (season != null && episode != null) {
listOf("${contentId}_s${season}e${episode}", contentId)
} else {
val matchingLocalKeys = watchProgressPreferences
.getAllRawEntries()
val matchingLocalKeys = rawEntries
.keys
.filter { key ->
key == contentId || key.startsWith("${contentId}_")
}
matchingLocalKeys + contentId
}
return keys
val resolvedKeys = keys
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
return resolvedKeys
}
private fun mergeProgressLists(
remoteItems: List<WatchProgress>,
localItems: List<WatchProgress>
): List<WatchProgress> {
val mergedByKey = linkedMapOf<String, WatchProgress>()
fun upsert(progress: WatchProgress) {
val key = progressKey(progress)
val existing = mergedByKey[key]
if (existing == null || shouldPreferProgress(existing, progress)) {
mergedByKey[key] = progress
}
}
remoteItems.forEach(::upsert)
localItems.forEach(::upsert)
return mergedByKey.values
.sortedByDescending { it.lastWatched }
}
private fun shouldPreferProgress(existing: WatchProgress, candidate: WatchProgress): Boolean {
val timeDiffMs = candidate.lastWatched - existing.lastWatched
if (timeDiffMs > 1_000L) return true
if (timeDiffMs < -1_000L) return false
val candidateInProgress = candidate.isInProgress()
val existingInProgress = existing.isInProgress()
if (candidateInProgress && !existingInProgress) return true
if (!candidateInProgress && existingInProgress) return false
val candidateIsPlayback = candidate.source == WatchProgress.SOURCE_TRAKT_PLAYBACK
val existingIsPlayback = existing.source == WatchProgress.SOURCE_TRAKT_PLAYBACK
if (candidateIsPlayback && !existingIsPlayback) return true
if (!candidateIsPlayback && existingIsPlayback) return false
return false
}
}

View file

@ -4,8 +4,13 @@ import android.net.Uri
import android.util.Log
import com.google.gson.Gson
import com.nuvio.tv.BuildConfig
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withTimeout
import okhttp3.Headers
import okhttp3.OkHttpClient
@ -137,6 +142,7 @@ class InAppYouTubeExtractor @Inject constructor() {
private val gson = Gson()
private val httpClient = OkHttpClient.Builder()
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
@ -170,7 +176,7 @@ class InAppYouTubeExtractor @Inject constructor() {
source
}
private fun extractPlaybackSourceInternal(youtubeUrl: String): TrailerPlaybackSource? {
private suspend fun extractPlaybackSourceInternal(youtubeUrl: String): TrailerPlaybackSource? {
val videoId = extractVideoId(youtubeUrl) ?: return null
val watchUrl = "https://www.youtube.com/watch?v=$videoId&hl=en"
@ -329,8 +335,8 @@ class InAppYouTubeExtractor @Inject constructor() {
bestProgressive?.url
}
val videoUrl = bestVideo?.url ?: combinedUrl ?: return null
val audioUrl = bestAudio?.url
val videoUrl = resolveReachableUrl(bestVideo?.url ?: combinedUrl ?: return null)
val audioUrl = bestAudio?.url?.let { resolveReachableUrl(it) }
if (BuildConfig.DEBUG) {
Log.d(
@ -592,6 +598,64 @@ class InAppYouTubeExtractor @Inject constructor() {
return sortCandidates(items).firstOrNull()
}
private suspend fun resolveReachableUrl(url: String): String {
if (!url.contains("googlevideo.com")) return url
val uri = Uri.parse(url)
val mnParam = uri.getQueryParameter("mn") ?: return url
val servers = mnParam.split(",").map { it.trim() }.filter { it.isNotBlank() }
if (servers.size < 2) return url
val candidates = mutableListOf(url)
for (server in servers) {
val mviIndex = servers.indexOf(server)
val altHost = uri.host?.replaceFirst(
Regex("^rr\\d+---"),
"rr${mviIndex + 1}---"
)?.replaceFirst(
Regex("sn-[a-z0-9]+-[a-z0-9]+"),
server
) ?: continue
if (altHost == uri.host) continue
candidates += url.replace(uri.host!!, altHost)
}
if (candidates.size == 1) return candidates[0]
val result = CompletableDeferred<String>()
val probeScope = CoroutineScope(Dispatchers.IO)
candidates.forEach { candidate ->
probeScope.launch {
val reachable = isUrlReachable(candidate)
Log.d(TAG, "CDN probe: ${Uri.parse(candidate).host} -> $reachable")
if (reachable) result.complete(candidate)
}
}
return try {
withTimeoutOrNull(2_000L) { result.await() } ?: url
} finally {
probeScope.cancel()
}
}
private val probeClient = OkHttpClient.Builder()
.dns(com.nuvio.tv.core.network.IPv4FirstDns())
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(2, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
private fun isUrlReachable(url: String): Boolean {
return runCatching {
val request = Request.Builder()
.url(url)
.get()
.header("Range", "bytes=0-0")
.headers(buildHeaders(DEFAULT_HEADERS))
.build()
probeClient.newCall(request).execute().use { val code = it.code; Log.d(TAG, "CDN probe code: ${Uri.parse(url).host} -> $code"); code == 200 }
}.getOrDefault(false)
}
private fun absolutizeUrl(baseUrl: String, maybeRelative: String): String {
return runCatching {
URL(URL(baseUrl), maybeRelative).toString()

View file

@ -6,8 +6,10 @@ import com.nuvio.tv.data.local.TmdbSettingsDataStore
import com.nuvio.tv.data.remote.api.TmdbApi
import com.nuvio.tv.data.remote.api.TmdbVideoResult
import com.nuvio.tv.data.remote.api.TrailerApi
import java.time.Clock
import java.net.URI
import java.time.Instant
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@ -17,20 +19,39 @@ import kotlinx.coroutines.withContext
private const val TAG = "TrailerService"
private const val TMDB_TRAILER_FALLBACK_LANGUAGE = "en-US"
private val YOUTUBE_SOURCE_CACHE_TTL: Duration = Duration.ofHours(3)
private val YOUTUBE_VIDEO_ID_REGEX = Regex("^[a-zA-Z0-9_-]{11}$")
@Singleton
class TrailerService @Inject constructor(
class TrailerService(
private val trailerApi: TrailerApi,
private val tmdbApi: TmdbApi,
private val inAppYouTubeExtractor: InAppYouTubeExtractor,
private val tmdbSettingsDataStore: TmdbSettingsDataStore,
private val tmdbService: TmdbService
private val tmdbService: TmdbService,
private val clock: Clock
) {
// Cache: "title|year|tmdbId|type" -> trailer playback source (null for negative cache)
private val cache = ConcurrentHashMap<String, TrailerPlaybackSource?>()
// Session cache: youtubeVideoId -> resolved playback source (success-only)
private val youtubeSourceCache = ConcurrentHashMap<String, TrailerPlaybackSource>()
@Inject
constructor(
trailerApi: TrailerApi,
tmdbApi: TmdbApi,
inAppYouTubeExtractor: InAppYouTubeExtractor,
tmdbSettingsDataStore: TmdbSettingsDataStore,
tmdbService: TmdbService
) : this(
trailerApi = trailerApi,
tmdbApi = tmdbApi,
inAppYouTubeExtractor = inAppYouTubeExtractor,
tmdbSettingsDataStore = tmdbSettingsDataStore,
tmdbService = tmdbService,
clock = Clock.systemUTC()
)
// Cache: "title|year|tmdbId|type" -> trailer playback source (NEGATIVE_CACHE sentinel for misses)
private val cache = ConcurrentHashMap<String, TrailerPlaybackSource>()
private val NEGATIVE_CACHE = TrailerPlaybackSource(videoUrl = "")
// Time-bound cache: youtubeVideoId -> resolved playback source (success-only)
private val youtubeSourceCache = ConcurrentHashMap<String, CachedTrailerPlaybackSource>()
/**
* Search for a trailer by title, year, tmdbId, and type.
@ -44,10 +65,10 @@ class TrailerService @Inject constructor(
): TrailerPlaybackSource? = withContext(Dispatchers.IO) {
val cacheKey = "$title|$year|$tmdbId|$type"
if (cache.containsKey(cacheKey)) {
val cached = cache[cacheKey]
Log.d(TAG, "Cache hit for $cacheKey: ${cached != null}")
return@withContext cached
cache[cacheKey]?.let { cached ->
val hit = cached !== NEGATIVE_CACHE
Log.d(TAG, "Cache hit for $cacheKey: $hit")
return@withContext if (hit) cached else null
}
try {
@ -65,7 +86,7 @@ class TrailerService @Inject constructor(
return@withContext tmdbSource
}
Log.w(TAG, "TMDB path exhausted; no YouTube trailer key resolved for backend /trailer fallback")
cache[cacheKey] = null
cache[cacheKey] = NEGATIVE_CACHE
null
} catch (e: Exception) {
Log.e(TAG, "Error fetching trailer for $title: ${e.message}", e)
@ -155,8 +176,8 @@ class TrailerService @Inject constructor(
try {
val youtubeKey = extractYouTubeVideoId(youtubeUrl)
if (!youtubeKey.isNullOrBlank()) {
youtubeSourceCache[youtubeKey]?.let { cached ->
Log.d(TAG, "YouTube session cache hit for key=${obfuscateYoutubeKey(youtubeKey)}")
getValidCachedYoutubeSource(youtubeKey)?.let { cached ->
Log.d(TAG, "YouTube cache hit for key=${obfuscateYoutubeKey(youtubeKey)}")
return@withContext cached
}
}
@ -165,7 +186,10 @@ class TrailerService @Inject constructor(
val localSource = inAppYouTubeExtractor.extractPlaybackSource(youtubeUrl)
if (localSource != null) {
if (!youtubeKey.isNullOrBlank()) {
youtubeSourceCache[youtubeKey] = localSource
youtubeSourceCache[youtubeKey] = CachedTrailerPlaybackSource(
playbackSource = localSource,
cachedAt = Instant.now(clock)
)
}
Log.d(
TAG,
@ -187,7 +211,10 @@ class TrailerService @Inject constructor(
if (!isValidUrl(fallbackUrl)) return@withContext null
if (!youtubeKey.isNullOrBlank()) {
youtubeSourceCache[youtubeKey] = TrailerPlaybackSource(videoUrl = fallbackUrl)
youtubeSourceCache[youtubeKey] = CachedTrailerPlaybackSource(
playbackSource = TrailerPlaybackSource(videoUrl = fallbackUrl),
cachedAt = Instant.now(clock)
)
}
Log.d(TAG, "Using backend fallback source for ${summarizeUrl(youtubeUrl)}")
TrailerPlaybackSource(videoUrl = fallbackUrl)
@ -292,6 +319,18 @@ class TrailerService @Inject constructor(
return "***${key.takeLast(4)}"
}
private fun getValidCachedYoutubeSource(youtubeKey: String): TrailerPlaybackSource? {
val cached = youtubeSourceCache[youtubeKey] ?: return null
val age = Duration.between(cached.cachedAt, Instant.now(clock))
if (age <= YOUTUBE_SOURCE_CACHE_TTL) {
return cached.playbackSource
}
youtubeSourceCache.remove(youtubeKey, cached)
Log.d(TAG, "YouTube cache expired for key=${obfuscateYoutubeKey(youtubeKey)} age=${age.toMinutes()}m")
return null
}
fun clearCache() {
cache.clear()
youtubeSourceCache.clear()
@ -339,6 +378,11 @@ class TrailerService @Inject constructor(
}
}.getOrNull()
}
private data class CachedTrailerPlaybackSource(
val playbackSource: TrailerPlaybackSource,
val cachedAt: Instant
)
}
internal fun normalizeTmdbTrailerLanguage(language: String?): String {

View file

@ -0,0 +1,143 @@
package com.nuvio.tv.data.trailer
import android.net.Uri
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.TransferListener
/**
* A DataSource.Factory that wraps DefaultHttpDataSource and appends YouTube's
* `&range=start-end` query parameter on each request. YouTube throttles (and
* kills) connections that try to download full adaptive streams in one shot,
* but honours chunked range-param requests at full speed.
*
* Only activates for googlevideo.com URLs; all other URLs pass through untouched.
*/
@UnstableApi
class YoutubeChunkedDataSourceFactory(
private val chunkSizeBytes: Long = CHUNK_SIZE
) : DataSource.Factory {
companion object {
private const val TAG = "YTChunkedDS"
/** 10 MB chunks large enough to avoid too many requests, small enough to dodge throttle. */
private const val CHUNK_SIZE = 10L * 1024 * 1024
}
override fun createDataSource(): DataSource {
val upstream = DefaultHttpDataSource.Factory()
.setConnectTimeoutMs(15_000)
.setReadTimeoutMs(15_000)
.setAllowCrossProtocolRedirects(true)
.createDataSource()
return YoutubeChunkedDataSource(upstream, chunkSizeBytes)
}
private class YoutubeChunkedDataSource(
private val upstream: DefaultHttpDataSource,
private val chunkSize: Long
) : DataSource {
private var currentUri: Uri? = null
private var isYouTubeStream = false
private var totalContentLength = C.LENGTH_UNSET.toLong()
private var currentChunkStart = 0L
private var currentChunkEnd = 0L
private var bytesReadInChunk = 0L
private var originalDataSpec: DataSpec? = null
override fun addTransferListener(transferListener: TransferListener) {
upstream.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec): Long {
val uri = dataSpec.uri
val host = uri.host.orEmpty()
isYouTubeStream = host.contains("googlevideo.com")
if (!isYouTubeStream) {
return upstream.open(dataSpec)
}
originalDataSpec = dataSpec
currentChunkStart = dataSpec.position
totalContentLength = dataSpec.length
return openNextChunk()
}
private fun openNextChunk(): Long {
val spec = originalDataSpec ?: throw IllegalStateException("No DataSpec")
val end = if (totalContentLength != C.LENGTH_UNSET.toLong()) {
minOf(currentChunkStart + chunkSize - 1, currentChunkStart + totalContentLength - 1)
} else {
currentChunkStart + chunkSize - 1
}
currentChunkEnd = end
// Append &range=start-end to the URL (YouTube's own range param, not HTTP Range header)
val rangedUri = spec.uri.buildUpon()
.appendQueryParameter("range", "$currentChunkStart-$currentChunkEnd")
.build()
val chunkedSpec = spec.buildUpon()
.setUri(rangedUri)
.setPosition(0) // position within this chunk's response
.setLength(C.LENGTH_UNSET.toLong()) // let the server decide
.build()
bytesReadInChunk = 0
upstream.open(chunkedSpec)
return if (totalContentLength != C.LENGTH_UNSET.toLong()) totalContentLength else C.LENGTH_UNSET.toLong()
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (!isYouTubeStream) {
return upstream.read(buffer, offset, length)
}
val bytesRead = upstream.read(buffer, offset, length)
if (bytesRead == C.RESULT_END_OF_INPUT) {
// Current chunk exhausted — open the next one
val chunkBytesReceived = bytesReadInChunk
upstream.close()
// If this chunk returned fewer bytes than requested, the stream is done
if (chunkBytesReceived < (currentChunkEnd - currentChunkStart + 1)) {
return C.RESULT_END_OF_INPUT
}
currentChunkStart += chunkBytesReceived
if (totalContentLength != C.LENGTH_UNSET.toLong()) {
totalContentLength -= chunkBytesReceived
if (totalContentLength <= 0) {
return C.RESULT_END_OF_INPUT
}
}
return try {
openNextChunk()
upstream.read(buffer, offset, length)
} catch (e: Exception) {
Log.w(TAG, "Failed to open next chunk at $currentChunkStart: ${e.message}")
C.RESULT_END_OF_INPUT
}
}
bytesReadInChunk += bytesRead
return bytesRead
}
override fun getUri(): Uri? = upstream.uri ?: currentUri
override fun close() {
upstream.close()
currentUri = null
originalDataSpec = null
}
}
}

View file

@ -10,11 +10,18 @@ data class Addon(
val version: String,
val description: String?,
val logo: String?,
val background: String? = null,
val baseUrl: String,
val catalogs: List<CatalogDescriptor>,
val types: List<ContentType>,
val rawTypes: List<String> = types.map { it.toApiString() },
val resources: List<AddonResource>
val resources: List<AddonResource>,
val idPrefixes: List<String> = emptyList(),
val behaviorHints: AddonBehaviorHints? = null,
val stremioAddonsConfig: StremioAddonsConfig? = null,
val manifestLanguage: String? = null,
val configVersion: Long? = null,
val timestamp: Long? = null
)
@Immutable
@ -23,7 +30,12 @@ data class CatalogDescriptor(
val rawType: String = type.toApiString(),
val id: String,
val name: String,
val extra: List<CatalogExtra> = emptyList()
val extra: List<CatalogExtra> = emptyList(),
val pageSize: Int? = null,
val showInHome: Boolean = false,
val hasExplicitShowInHome: Boolean = false,
val extraSupported: List<String> = emptyList(),
val extraRequired: List<String> = emptyList()
) {
val apiType: String
get() = type.toApiString(rawType)
@ -33,7 +45,9 @@ data class CatalogDescriptor(
data class CatalogExtra(
val name: String,
val isRequired: Boolean = false,
val options: List<String>? = null
val options: List<String>? = null,
val defaultValue: String? = null,
val optionsLimit: Int? = null
)
@Immutable
@ -42,3 +56,16 @@ data class AddonResource(
val types: List<String>,
val idPrefixes: List<String>?
)
@Immutable
data class AddonBehaviorHints(
val configurable: Boolean? = null,
val configurationRequired: Boolean? = null,
val newEpisodeNotifications: Boolean? = null
)
@Immutable
data class StremioAddonsConfig(
val issuer: String? = null,
val signature: String? = null
)

View file

@ -0,0 +1,7 @@
package com.nuvio.tv.domain.model
enum class AppFont(val displayName: String) {
INTER("Inter"),
DM_SANS("DM Sans"),
OPEN_SANS("Open Sans")
}

View file

@ -0,0 +1,12 @@
package com.nuvio.tv.domain.model
private const val DEFAULT_SKIP_STEP = 100
fun CatalogDescriptor.supportsExtra(name: String): Boolean {
return extra.any { it.name.equals(name, ignoreCase = true) }
}
fun CatalogDescriptor.skipStep(defaultStep: Int = DEFAULT_SKIP_STEP): Int {
if (pageSize != null && pageSize > 0) return pageSize
return defaultStep
}

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