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:
commit
32aa5cf46a
300 changed files with 92083 additions and 7662 deletions
220
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
220
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||
78
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
42
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
182
.github/workflows/beta-release.yml
vendored
Normal 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[@]}"
|
||||
83
.github/workflows/close-unlabeled-issues.yml
vendored
Normal file
83
.github/workflows/close-unlabeled-issues.yml
vendored
Normal 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
75
.github/workflows/pr-template-check.yml
vendored
Normal 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
75
.github/workflows/stale-needs-info.yml
vendored
Normal 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
164
.github/workflows/triage-needs-info.yml
vendored
Normal 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
8
.gitignore
vendored
|
|
@ -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
81
CONTRIBUTING.md
Normal 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.
|
||||
|
|
@ -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.
Binary file not shown.
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
app/src/main/java/com/nuvio/tv/core/network/IPv4FirstDns.kt
Normal file
17
app/src/main/java/com/nuvio/tv/core/network/IPv4FirstDns.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 regex‑matching 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 }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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__"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
7
app/src/main/java/com/nuvio/tv/domain/model/AppFont.kt
Normal file
7
app/src/main/java/com/nuvio/tv/domain/model/AppFont.kt
Normal 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")
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue