Compare commits

..

1 commit

Author SHA1 Message Date
Osten
5e7c6563c7 imported torrent into jnilibs for lazy loading to avoid potential crashes. 2025-02-26 02:03:45 +01:00
730 changed files with 28847 additions and 71263 deletions

30
.github/locales.py vendored
View file

@ -1,13 +1,14 @@
import re import re
import glob import glob
import requests import requests
import os
import lxml.etree as ET # builtin library doesn't preserve comments import lxml.etree as ET # builtin library doesn't preserve comments
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
START_MARKER = "/* begin language list */" START_MARKER = "/* begin language list */"
END_MARKER = "/* end language list */" END_MARKER = "/* end language list */"
XML_NAME = "app/src/main/res/values-b+" XML_NAME = "app/src/main/res/values-"
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
INDENT = " "*4 INDENT = " "*4
@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER)
# Load already added langs # Load already added langs
languages = {} languages = {}
for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
name, iso = lang.groups() flag, name, iso = lang.groups()
languages[iso] = name languages[iso] = (flag, name)
# Add not yet added langs # Add not yet added langs
for folder in glob.glob(f"{XML_NAME}*"): for folder in glob.glob(f"{XML_NAME}*"):
iso = folder[len(XML_NAME):].replace("+", "-") iso = folder[len(XML_NAME):]
if iso not in languages.keys(): if iso not in languages.keys():
entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found entry = iso_map.get(iso.lower(),{'nativeName':iso})
languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple languages[iso] = ("", entry['nativeName'].split(',')[0])
# Create pairs # Create triples
pairs = [] triples = []
for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name for iso in sorted(languages.keys()):
name = languages[iso] flag, name = languages[iso]
pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
# Update settings file # Update settings file
open(SETTINGS_PATH, "w+",encoding='utf-8').write( open(SETTINGS_PATH, "w+",encoding='utf-8').write(
before_src + before_src +
START_MARKER + START_MARKER +
"\n" + "\n" +
"\n".join(pairs) + "\n".join(triples) +
"\n" + "\n" +
END_MARKER + END_MARKER +
after_src after_src
@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
with open(file, 'wb') as fp: with open(file, 'wb') as fp:
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n') fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
# Remove trailing new line to be consistent with weblate
fp.seek(-1, os.SEEK_END)
fp.truncate()
except ET.ParseError as ex: except ET.ParseError as ex:
print(f"[{file}] {ex}") print(f"[{file}] {ex}")

View file

@ -1,95 +1,78 @@
name: Archive build name: Archive build
on: on:
push: push:
branches: [ master ] branches: [ master ]
paths-ignore: paths-ignore:
- '*.md' - '*.md'
- '*.json' - '*.json'
- '**/wcokey.txt' - '**/wcokey.txt'
workflow_dispatch: workflow_dispatch:
permissions: concurrency:
contents: read group: "Archive-build"
cancel-in-progress: true
concurrency:
group: "Archive-build" jobs:
cancel-in-progress: true build:
runs-on: ubuntu-latest
jobs: steps:
build: - name: Generate access token
runs-on: ubuntu-latest id: generate_token
steps: uses: tibdex/github-app-token@v2
- name: Generate access token with:
id: generate_token app_id: ${{ secrets.GH_APP_ID }}
uses: tibdex/github-app-token@v2 private_key: ${{ secrets.GH_APP_KEY }}
with: repository: "recloudstream/secrets"
app_id: ${{ secrets.GH_APP_ID }} - name: Generate access token (archive)
private_key: ${{ secrets.GH_APP_KEY }} id: generate_archive_token
repository: "recloudstream/secrets" uses: tibdex/github-app-token@v2
with:
- name: Generate access token (archive) app_id: ${{ secrets.GH_APP_ID }}
id: generate_archive_token private_key: ${{ secrets.GH_APP_KEY }}
uses: tibdex/github-app-token@v2 repository: "recloudstream/cloudstream-archive"
with: - uses: actions/checkout@v4
app_id: ${{ secrets.GH_APP_ID }} - name: Set up JDK 17
private_key: ${{ secrets.GH_APP_KEY }} uses: actions/setup-java@v4
repository: "recloudstream/cloudstream-archive" with:
java-version: '17'
- uses: actions/checkout@v6 distribution: 'adopt'
- name: Grant execute permission for gradlew
- name: Set up JDK 17 run: chmod +x gradlew
uses: actions/setup-java@v5 - name: Fetch keystore
with: id: fetch_keystore
distribution: temurin run: |
java-version: 17 TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore
mkdir -p "${TMP_KEYSTORE_FILE_PATH}"
- name: Grant execute permission for gradlew curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks"
run: chmod +x gradlew curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt"
KEY_PWD="$(cat keystore_password.txt)"
- name: Fetch keystore echo "::add-mask::${KEY_PWD}"
id: fetch_keystore echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
run: | - name: Run Gradle
TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore run: |
mkdir -p "${TMP_KEYSTORE_FILE_PATH}" ./gradlew assemblePrerelease
curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks" env:
curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt" SIGNING_KEY_ALIAS: "key0"
KEY_PWD="$(cat keystore_password.txt)" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
echo "::add-mask::${KEY_PWD}" SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- name: Setup Gradle - uses: actions/checkout@v4
uses: gradle/actions/setup-gradle@v5 with:
with: repository: "recloudstream/cloudstream-archive"
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} token: ${{ steps.generate_archive_token.outputs.token }}
path: "archive"
- name: Run Gradle
run: ./gradlew assemblePrereleaseRelease - name: Move build
env: run: |
SIGNING_KEY_ALIAS: "key0" cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} - name: Push archive
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} run: |
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} cd $GITHUB_WORKSPACE/archive
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} git config --local user.email "actions@github.com"
MDL_API_KEY: ${{ secrets.MDL_API_KEY }} git config --local user.name "GitHub Actions"
MAL_KEY: ${{ secrets.MAL_KEY }} git add .
ANILIST_KEY: ${{ secrets.ANILIST_KEY }} git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit
git push --force
- uses: actions/checkout@v6
with:
repository: "recloudstream/cloudstream-archive"
token: ${{ steps.generate_archive_token.outputs.token }}
path: "archive"
- name: Move build
run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
- name: Push archive
run: |
cd $GITHUB_WORKSPACE/archive
git config --local user.email "actions@github.com"
git config --local user.name "GitHub Actions"
git add .
git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit
git push --force

View file

@ -1,18 +1,19 @@
name: Dokka name: Dokka
# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
concurrency:
group: "dokka"
cancel-in-progress: true
on: on:
push: push:
branches: [ master ] branches:
# choose your default branch
- master
- main
paths-ignore: paths-ignore:
- '*.md' - '*.md'
permissions:
contents: read
concurrency:
group: "dokka"
cancel-in-progress: true
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -24,35 +25,32 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/dokka" repository: "recloudstream/dokka"
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@master
with: with:
path: "src" path: "src"
- name: Checkout dokka - name: Checkout dokka
uses: actions/checkout@v6 uses: actions/checkout@master
with: with:
repository: "recloudstream/dokka" repository: "recloudstream/dokka"
path: "dokka" path: "dokka"
token: ${{ steps.generate_token.outputs.token }} token: ${{ steps.generate_token.outputs.token }}
- name: Clean old builds - name: Clean old builds
run: | run: |
cd $GITHUB_WORKSPACE/dokka/ cd $GITHUB_WORKSPACE/dokka/
rm -rf "./app" rm -rf "./app"
rm -rf "./library" rm -rf "./library"
- name: Set up JDK 17 - name: Setup JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: temurin
java-version: 17 java-version: 17
distribution: 'adopt'
- name: Setup Gradle - name: Setup Android SDK
uses: gradle/actions/setup-gradle@v5 uses: android-actions/setup-android@v3
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Generate Dokka - name: Generate Dokka
run: | run: |
@ -61,7 +59,8 @@ jobs:
./gradlew docs:dokkaGeneratePublicationHtml ./gradlew docs:dokkaGeneratePublicationHtml
- name: Copy Dokka - name: Copy Dokka
run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ run: |
cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
- name: Push builds - name: Push builds
run: | run: |

View file

@ -1,105 +0,0 @@
name: Instrumented Tests
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
instrumented-tests:
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
github.event.comment.body == '/run-tests'
steps:
- name: Check permission
uses: actions/github-script@v9
with:
script: |
const login = context.payload.comment.user.login;
const association = context.payload.comment.author_association;
const allowed = ['OWNER', 'MEMBER'];
const isAllowed =
login === 'Luna712' ||
allowed.includes(association);
if (!isAllowed) {
core.setFailed(`User ${login} is not permitted to trigger this workflow.`);
}
- uses: actions/checkout@v6
with:
ref: refs/pull/${{ github.event.issue.number }}/head
- name: Post started comment
uses: actions/github-script@v9
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Instrumented tests are running. [View live progress](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
})
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache-read-only: false
- name: Get target SDK
id: sdk
run: |
TARGET_SDK=$(grep 'targetSdk' gradle/libs.versions.toml | grep -o '[0-9]\+' | head -1)
echo "version=$TARGET_SDK" >> $GITHUB_OUTPUT
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run Instrumented Tests
id: tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ steps.sdk.outputs.version }}
arch: x86_64
profile: Nexus 6
script: ./gradlew connectedPrereleaseDebugAndroidTest
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v7
with:
name: instrumented-test-results
path: '**/build/reports/androidTests/'
- name: Post finished comment
if: always()
uses: actions/github-script@v9
with:
script: |
const success = '${{ steps.tests.outcome }}' === 'success';
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: success
? 'Instrumented tests passed. [View results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
: 'Instrumented tests failed. [View results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
})

88
.github/workflows/issue_action.yml vendored Normal file
View file

@ -0,0 +1,88 @@
name: Issue automatic actions
on:
issues:
types: [opened]
jobs:
issue-moderator:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
- name: Similarity analysis
id: similarity
uses: actions-cool/issues-similarity-analysis@v1
with:
token: ${{ steps.generate_token.outputs.token }}
filter-threshold: 0.60
title-excludes: ''
comment-title: |
### Your issue looks similar to these issues:
Please close if duplicate.
comment-body: '${index}. ${similarity} #${number}'
- name: Label if possible duplicate
if: steps.similarity.outputs.similar-issues-found =='true'
uses: actions/github-script@v7
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["possible duplicate"]
})
- uses: actions/checkout@v4
- name: Automatically close issues that dont follow the issue template
uses: lucasbento/auto-close-issues@v1.0.2
with:
github-token: ${{ steps.generate_token.outputs.token }}
issue-close-message: |
@${issue.user.login}: hello! :wave:
This issue is being automatically closed because it does not follow the issue template."
closed-issues-label: "invalid"
- name: Check if issue mentions a provider
id: provider_check
env:
GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}"
run: |
wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py"
pip3 install httpx
RES="$(python3 ./check_issue.py)"
echo "name=${RES}" >> $GITHUB_OUTPUT
- name: Comment if issue mentions a provider
if: steps.provider_check.outputs.name != 'none'
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment'
token: ${{ steps.generate_token.outputs.token }}
body: |
Hello ${{ github.event.issue.user.login }}.
Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
Found provider name: `${{ steps.provider_check.outputs.name }}`
- name: Label if mentions provider
if: steps.provider_check.outputs.name != 'none'
uses: actions/github-script@v7
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["possible provider issue"]
})
- name: Add eyes reaction to all issues
uses: actions-cool/emoji-helper@v1.0.0
with:
type: 'issue'
token: ${{ steps.generate_token.outputs.token }}
emoji: 'eyes'

View file

@ -8,13 +8,10 @@ on:
- '*.json' - '*.json'
- '**/wcokey.txt' - '**/wcokey.txt'
concurrency: concurrency:
group: "pre-release" group: "pre-release"
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: write
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -26,18 +23,14 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets" repository: "recloudstream/secrets"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: temurin java-version: '17'
java-version: 17 distribution: 'adopt'
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Fetch keystore - name: Fetch keystore
id: fetch_keystore id: fetch_keystore
run: | run: |
@ -48,27 +41,18 @@ jobs:
KEY_PWD="$(cat keystore_password.txt)" KEY_PWD="$(cat keystore_password.txt)"
echo "::add-mask::${KEY_PWD}" echo "::add-mask::${KEY_PWD}"
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Run Gradle - name: Run Gradle
run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar run: |
./gradlew assemblePrerelease build androidSourcesJar
./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
env: env:
SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
MAL_KEY: ${{ secrets.MAL_KEY }}
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
- name: Create pre-release - name: Create pre-release
uses: marvinpinto/action-automatic-releases@latest uses: "marvinpinto/action-automatic-releases@latest"
with: with:
repo_token: "${{ secrets.GITHUB_TOKEN }}" repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "pre-release" automatic_release_tag: "pre-release"

View file

@ -2,40 +2,22 @@ name: Artifact Build
on: [pull_request] on: [pull_request]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: temurin java-version: '17'
java-version: 17 distribution: 'adopt'
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache-read-only: false
- name: Ensure binary compatibility
# This is to ensure that your code is backwards compatible.
# If this fails you need to add a @Prerelease annotation to new code.
run: ./gradlew library:checkKotlinAbi
- name: Run Gradle - name: Run Gradle
run: ./gradlew assemblePrereleaseDebug lint check run: ./gradlew assemblePrereleaseDebug
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: pull-request-build name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk" path: "app/build/outputs/apk/prerelease/debug/*.apk"

View file

@ -1,19 +1,17 @@
name: Fix locale issues name: Fix locale issues
on: on:
workflow_dispatch:
push: push:
branches: [ master ]
paths: paths:
- '**.xml' - '**.xml'
workflow_dispatch: branches:
- master
concurrency: concurrency:
group: "locale" group: "locale"
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
create: create:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -25,17 +23,15 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream" repository: "recloudstream/cloudstream"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with: with:
token: ${{ steps.generate_token.outputs.token }} token: ${{ steps.generate_token.outputs.token }}
- name: Install dependencies - name: Install dependencies
run: pip3 install lxml requests run: |
pip3 install lxml
- name: Edit files - name: Edit files
run: python3 .github/locales.py run: |
python3 .github/locales.py
- name: Commit to the repo - name: Commit to the repo
run: | run: |
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"

220
.gitignore vendored
View file

@ -1,3 +1,5 @@
*.iml
.gradle
/local.properties /local.properties
/.idea/caches /.idea/caches
/.idea/misc.xml /.idea/misc.xml
@ -9,220 +11,6 @@
.DS_Store .DS_Store
/build /build
/captures /captures
.cxx
.kotlin/*
# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode
# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode
### Android ###
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
### Android Patch ###
gen-external-apklibs
# Replacement of .externalNativeBuild directories introduced
# with Android Studio 3.5.
### Java ###
# Compiled class file
*.class
# Log file
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
### Kotlin ###
# Compiled class file
# Log file
# BlueJ files
# Mobile Tools for Java (J2ME)
# Package Files #
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
### VisualStudioCode ###
.vscode/*
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### AndroidStudio ###
# Covers files to be ignored for android development using Android Studio.
# Built application files
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
# Generated files
bin/
gen/
out/
# Gradle files
.gradle
# Signing files
.signing/
# Local configuration file (sdk path, etc)
# Proguard folder generated by Eclipse
proguard/
# Log Files
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
.navigation/
*.ipr
*~
*.swp
# Keystore files
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Android Patch
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild .externalNativeBuild
.cxx
# NDK local.properties
obj/
# IntelliJ IDEA
*.iws
/out/
# User-specific configurations
.idea/caches/
.idea/libraries/
.idea/shelf/
.idea/workspace.xml
.idea/tasks.xml
.idea/.name
.idea/compiler.xml
.idea/copyright/profiles_settings.xml
.idea/encodings.xml
.idea/misc.xml
.idea/modules.xml
.idea/scopes/scope_settings.xml
.idea/dictionaries
.idea/vcs.xml
.idea/jsLibraryMappings.xml
.idea/datasources.xml
.idea/dataSources.ids
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml
.idea/assetWizardSettings.xml
.idea/gradle.xml
.idea/jarRepositories.xml
.idea/navEditor.xml
# Legacy Eclipse project files
.classpath
.project
.cproject
.settings/
# Mobile Tools for Java (J2ME)
# Package Files #
# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
## Plugin-specific files:
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Mongo Explorer plugin
.idea/mongoSettings.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### AndroidStudio Patch ###
!/gradle/wrapper/gradle-wrapper.jar
# End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode

1
.idea/.name generated Normal file
View file

@ -0,0 +1 @@
CloudStream

123
.idea/codeStyles/Project.xml generated Normal file
View file

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

7
.idea/discord.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

21
.idea/gradle.xml generated Normal file
View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/docs" />
<option value="$PROJECT_DIR$/library" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

40
.idea/jarRepositories.xml generated Normal file
View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://github.com/psiegman/mvn-repo/raw/master/releases" />
</remote-repository>
<remote-repository>
<option name="id" value="maven2" />
<option name="name" value="maven2" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
</component>
</project>

6
.idea/studiobot.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedOut" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

6
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"githubPullRequests.ignoredPullRequestBranches": [
"master"
],
"java.configuration.updateBuildConfiguration": "interactive"
}

View file

@ -1,11 +0,0 @@
# AI Policy
AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions.
1. Always state any AI usage in pull requests and issues.
2. Always test code before making a pull request. We do not want to test your AI generated code.
3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI.
4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions.

View file

@ -1,21 +0,0 @@
# Migration guide to Compose
### 1. MVI instead of MVVM
The current design of CloudStream loosely uses the MVVM architecture.
This means that the UI invokes the viewmodel with function calls, and it responds with LiveData fields that are observed. While this has worked, it generates a lot of boilerplate and has created some friction.
To make it easier to work with Compose, the new architecture will be based on MVI. In short this means that the viewmodel exposes a singular immutable class that is observed, and receives all UI events with a singular event that is a sealed class. All the UI should be able to be recreated based on this singular state class, and all interactions should be able to be replayed using only the event callback.
For a more detailed overview, see: https://www.youtube.com/watch?v=b2z1jvD4VMQ
This is part of the effort to make CloudStream cross platform, as it allows us to decouple UI and logic.
### 2. KMP-compatible libraries
We plan to leverage Kotlin's KMP project to compile our code to different architectures. However, this requires us to only use KMP-compatible libraries, no Java. Therefore any pull requests must ensure that they use KMP-compatible libraries only.
### 3. UI Changes
While migrating to the new compose UI, you also have the opportunity to change the UI. However, this should only be to freshen up the UI, not completely redesign it. It is also important to stress that this process should not lose any features of the old UI, and be very conservative with adding new features.

View file

@ -13,7 +13,7 @@
+ [Bugs Reports:](#bug_report) + [Bugs Reports:](#bug_report)
+ [Enhancement:](#enhancment) + [Enhancement:](#enhancment)
+ [Extension Development:](#extensions) + [Extension Development:](#extensions)
+ [Language Support:](#languages) + [Languauge Support:](#languages)
+ [Further Sources](#contact_and_sources) + [Further Sources](#contact_and_sources)

View file

@ -1,96 +1,53 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
plugins { plugins {
alias(libs.plugins.android.application) id("com.android.application")
alias(libs.plugins.dokka) id("kotlin-android")
alias(libs.plugins.kotlin.serialization) id("org.jetbrains.dokka")
} }
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
abstract class GenerateGitHashTask : DefaultTask() { fun getGitCommitHash(): String {
return try {
val headFile = file("${project.rootDir}/.git/HEAD")
@get:InputFile // Read the commit hash from .git/HEAD
@get:PathSensitive(PathSensitivity.RELATIVE) if (headFile.exists()) {
abstract val headFile: RegularFileProperty val headContent = headFile.readText().trim()
if (headContent.startsWith("ref:")) {
@get:InputDirectory val refPath = headContent.substring(5) // e.g., refs/heads/main
@get:PathSensitive(PathSensitivity.RELATIVE) val commitFile = file("${project.rootDir}/.git/$refPath")
abstract val headsDir: DirectoryProperty if (commitFile.exists()) commitFile.readText().trim() else ""
} else headContent // If it's a detached HEAD (commit hash directly)
@get:OutputDirectory } else {
abstract val outputDir: DirectoryProperty "" // If .git/HEAD doesn't exist
}.take(7) // Return the short commit hash
@TaskAction } catch (_: Throwable) {
fun generate() { "" // Just return an empty string if any exception occurs
val head = headFile.get().asFile
val hash = try {
if (head.exists()) {
// Read the commit hash from .git/HEAD
val headContent = head.readText().trim()
if (headContent.startsWith("ref:")) {
val refPath = headContent.substring(5) // e.g., refs/heads/main
val commitFile = File(head.parentFile, refPath)
if (commitFile.exists()) commitFile.readText().trim() else ""
} else headContent // If it's a detached HEAD (commit hash directly)
} else "" // If .git/HEAD doesn't exist
} catch (_: Throwable) {
"" // Just set to an empty string if any exception occurs
}.take(7) // Get the short commit hash
val outFile = outputDir.file("git-hash.txt").get().asFile
outFile.parentFile.mkdirs()
outFile.writeText(hash)
} }
} }
val generateGitHash = tasks.register<GenerateGitHashTask>("generateGitHash") {
val gitDir = layout.projectDirectory.dir("../.git")
headFile.set(gitDir.file("HEAD"))
headsDir.set(gitDir.dir("refs/heads"))
outputDir.set(layout.buildDirectory.dir("generated/git"))
}
android { android {
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
} }
// Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 viewBinding {
dependenciesInfo { enable = true
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(
generateGitHash,
GenerateGitHashTask::outputDir
)
}
} }
signingConfigs { signingConfigs {
// We just use SIGNING_KEY_ALIAS here since it won't change if (prereleaseStoreFile != null) {
// so won't kill the configuration cache.
if (System.getenv("SIGNING_KEY_ALIAS") != null) {
create("prerelease") { create("prerelease") {
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" storeFile = file(prereleaseStoreFile)
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
storeFile = prereleaseStoreFile?.let { file(it) }
storePassword = System.getenv("SIGNING_STORE_PASSWORD") storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD") keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
@ -104,10 +61,12 @@ android {
applicationId = "com.lagradost.cloudstream3" applicationId = "com.lagradost.cloudstream3"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt() versionCode = 65
versionName = libs.versions.versionName.get() versionName = "4.5.1"
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", getGitCommitHash())
resValue("bool", "is_prerelease", "false")
// Reads local.properties // Reads local.properties
val localProperties = gradleLocalProperties(rootDir, project.providers) val localProperties = gradleLocalProperties(rootDir, project.providers)
@ -127,16 +86,6 @@ android {
"SIMKL_CLIENT_SECRET", "SIMKL_CLIENT_SECRET",
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
) )
buildConfigField(
"String",
"MAL_KEY",
"\"" + (System.getenv("MAL_KEY") ?: localProperties["mal.key"]) + "\""
)
buildConfigField(
"String",
"ANILIST_KEY",
"\"" + (System.getenv("ANILIST_KEY") ?: localProperties["anilist.key"]) + "\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@ -164,9 +113,12 @@ android {
productFlavors { productFlavors {
create("stable") { create("stable") {
dimension = "state" dimension = "state"
resValue("bool", "is_prerelease", "false")
} }
create("prerelease") { create("prerelease") {
dimension = "state" dimension = "state"
resValue("bool", "is_prerelease", "true")
buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease" applicationIdSuffix = ".prerelease"
if (signingConfigs.names.contains("prerelease")) { if (signingConfigs.names.contains("prerelease")) {
signingConfig = signingConfigs.getByName("prerelease") signingConfig = signingConfigs.getByName("prerelease")
@ -184,29 +136,13 @@ android {
targetCompatibility = JavaVersion.toVersion(javaTarget.target) targetCompatibility = JavaVersion.toVersion(javaTarget.target)
} }
java {
// Use Java 17 toolchain even if a higher JDK runs the build.
// We still use Java 8 for now which higher JDKs have deprecated.
toolchain {
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
}
}
lint { lint {
abortOnError = false
checkReleaseBuilds = false checkReleaseBuilds = false
} }
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
viewBinding = true
}
packaging {
jniLibs {
// Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23).
// Note: This may increase app startup time slightly.
useLegacyPackaging = true
}
} }
namespace = "com.lagradost.cloudstream3" namespace = "com.lagradost.cloudstream3"
@ -217,46 +153,44 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.json) testImplementation(libs.json)
androidTestImplementation(libs.core) androidTestImplementation(libs.core)
androidTestImplementation(libs.espresso.core) implementation(libs.junit.ktx)
androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.instancio.core) androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.junit.ktx)
androidTestImplementation(libs.kotlin.test)
// Android Core & Lifecycle // Android Core & Lifecycle
implementation(libs.core.ktx) implementation(libs.core.ktx)
implementation(libs.activity.ktx)
implementation(libs.annotation)
implementation(libs.appcompat) implementation(libs.appcompat)
implementation(libs.fragment.ktx) implementation(libs.navigation.ui.ktx)
implementation(libs.bundles.lifecycle) implementation(libs.lifecycle.livedata.ktx)
implementation(libs.bundles.navigation) implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.kotlinx.collections.immutable) implementation(libs.navigation.fragment.ktx)
implementation(libs.kotlinx.serialization.json) // JSON Parser
// Design & UI // Design & UI
implementation(libs.preference.ktx) implementation(libs.preference.ktx)
implementation(libs.material) implementation(libs.material)
implementation(libs.constraintlayout) implementation(libs.constraintlayout)
implementation(libs.swiperefreshlayout)
// Coil Image Loading // Coil Image Loading
implementation(libs.bundles.coil) implementation(libs.coil)
implementation(libs.coil.network.okhttp)
// Media 3 (ExoPlayer) // Media 3 (ExoPlayer)
implementation(libs.bundles.media3) implementation(libs.bundles.media3)
implementation(libs.video) implementation(libs.video)
// FFmpeg Decoding
implementation(libs.bundles.nextlib)
// Anime-db for filler
implementation(libs.anime.db)
// PlayBack // PlayBack
implementation(libs.colorpicker) // Subtitle Color Picker implementation(libs.colorpicker) // Subtitle Color Picker
implementation(libs.newpipeextractor) // For Trailers implementation(libs.newpipeextractor) // For Trailers
implementation(libs.juniversalchardet) // Subtitle Decoding implementation(libs.juniversalchardet) // Subtitle Decoding
// FFmpeg Decoding
implementation(libs.bundles.nextlibMedia3)
// Crash Reports (AcraApplication.kt)
implementation(libs.acra.core)
implementation(libs.acra.toast)
// UI Stuff // UI Stuff
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
implementation(libs.palette.ktx) // Palette for Images -> Colors implementation(libs.palette.ktx) // Palette for Images -> Colors
@ -267,37 +201,50 @@ dependencies {
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
// Extensions & Other Libs // Extensions & Other Libs
implementation(libs.jsoup) // HTML Parser
implementation(libs.ksoup) // HTML Parser
implementation(libs.rhino) // Run JavaScript implementation(libs.rhino) // Run JavaScript
implementation(libs.quickjs)
implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance
implementation(libs.safefile) // To Prevent the URI File Fu*kery implementation(libs.safefile) // To Prevent the URI File Fu*kery
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 implementation(libs.conscrypt.android) {
implementation(libs.jackson.module.kotlin) // JSON Parser version {
implementation(libs.zipline) strictly("2.5.2")
}
// Temp/deprecated; will be removed once extensions have time to migrate from using it because("2.5.3 crashes everything for everyone.")
implementation("com.google.code.gson:gson:2.11.0") } // To Fix SSL Fu*kery on Android 9
// Deprecated; will be removed once extensions have time to migrate from using it implementation(libs.jackson.module.kotlin) {
implementation("me.xdrop:fuzzywuzzy:1.4.0") version {
strictly("2.13.1")
}
because("Don't Bump Jackson above 2.13.1, Crashes on Android TV's and FireSticks that have Min API Level 25 or Less.")
} // JSON Parser
// Torrent Support // Torrent Support
implementation(libs.torrentserver) // implementation(libs.torrentserver)
// Downloading & Networking // Downloading & Networking
implementation(libs.work.runtime)
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
implementation(libs.nicehttp) // HTTP Lib implementation(libs.nicehttp) // HTTP Lib
implementation(project(":library")) implementation(project(":library") {
// There does not seem to be a good way of getting the android flavor.
val isDebug = gradle.startParameter.taskRequests.any { task ->
task.args.any { arg ->
arg.contains("debug", true)
}
}
this.extra.set("isDebug", isDebug)
})
} }
tasks.register<Jar>("androidSourcesJar") { tasks.register<Jar>("androidSourcesJar") {
archiveClassifier.set("sources") archiveClassifier.set("sources")
from(android.sourceSets.getByName("main").java.directories) // Full Sources from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
} }
tasks.register<Copy>("copyJar") { tasks.register<Copy>("copyJar") {
dependsOn("build", ":library:jvmJar")
from( from(
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
"../library/build/libs" "../library/build/libs"
@ -324,21 +271,15 @@ tasks.register<Jar>("makeJar") {
tasks.withType<KotlinJvmCompile> { tasks.withType<KotlinJvmCompile> {
compilerOptions { compilerOptions {
jvmTarget.set(javaTarget) jvmTarget.set(javaTarget)
jvmDefault.set(JvmDefaultMode.ENABLE) freeCompilerArgs.add("-Xjvm-default=all-compatibility")
optIn.addAll(
"com.lagradost.cloudstream3.InternalAPI",
"com.lagradost.cloudstream3.Prerelease",
)
} }
} }
dokka { dokka {
moduleName = "App" moduleName = "App"
dokkaSourceSets { dokkaSourceSets {
configureEach { main {
suppress = name != "prereleaseDebug"
analysisPlatform = KotlinPlatform.JVM analysisPlatform = KotlinPlatform.JVM
displayName = "JVM"
documentedVisibilities( documentedVisibilities(
VisibilityModifier.Public, VisibilityModifier.Public,
VisibilityModifier.Protected VisibilityModifier.Protected

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- ByteOrderMark has errors in values-b+ja/strings.xml, but it's handled by weblate so we don't really care. -->
<issue id="ByteOrderMark" severity="ignore" />
<!-- We don't care about MissingTranslation since it's handled by weblate. -->
<issue id="MissingTranslation" severity="ignore" />
<!-- We only care about the source language here. -->
<issue id="StringFormatInvalid">
<ignore path="**/res/values-*/**" />
</issue>
</lint>

View file

@ -7,7 +7,6 @@ import android.view.LayoutInflater
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
@ -55,6 +54,12 @@ class ExampleInstrumentedTest {
return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView } return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView }
} }
@Test
fun providersExist() {
Assert.assertTrue(getAllProviders().isNotEmpty())
println("Done providersExist")
}
@Throws @Throws
private inline fun <reified T : ViewBinding> testAllLayouts( private inline fun <reified T : ViewBinding> testAllLayouts(
activity: Activity, activity: Activity,
@ -83,8 +88,6 @@ class ExampleInstrumentedTest {
// testAllLayouts<ActivityMainBinding>(activity,R.layout.activity_main, R.layout.activity_main_tv) // testAllLayouts<ActivityMainBinding>(activity,R.layout.activity_main, R.layout.activity_main_tv)
//testAllLayouts<ActivityMainBinding>(activity, R.layout.activity_main_tv) //testAllLayouts<ActivityMainBinding>(activity, R.layout.activity_main_tv)
testAllLayouts<BottomResultviewPreviewBinding>(activity, R.layout.bottom_resultview_preview,R.layout.bottom_resultview_preview_tv)
testAllLayouts<FragmentPlayerBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv) testAllLayouts<FragmentPlayerBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
testAllLayouts<FragmentPlayerTvBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv) testAllLayouts<FragmentPlayerTvBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
@ -130,14 +133,14 @@ class ExampleInstrumentedTest {
@Test @Test
@Throws(AssertionError::class) @Throws(AssertionError::class)
fun providerCorrectData() { fun providerCorrectData() {
val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag } val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty()) Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
for (api in getAllProviders()) { for (api in getAllProviders()) {
Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE") Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE")
Assert.assertTrue("Api does not contain a name", api.name != "NONE") Assert.assertTrue("Api does not contain a name", api.name != "NONE")
Assert.assertTrue( Assert.assertTrue(
"Api ${api.name} does not contain a valid language code", "Api ${api.name} does not contain a valid language code",
langTagsIETF.contains(api.lang) isoNames.contains(api.lang)
) )
Assert.assertTrue( Assert.assertTrue(
"Api ${api.name} does not contain any supported types", "Api ${api.name} does not contain any supported types",

View file

@ -1,154 +0,0 @@
package com.lagradost.cloudstream3
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.lagradost.cloudstream3.SkipSerializationTest
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import dalvik.system.DexFile
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.serializer
import kotlinx.serialization.serializerOrNull
import org.instancio.Instancio
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.reflect.KClass
import kotlin.reflect.jvm.jvmName
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@RunWith(AndroidJUnit4::class)
class SerializationClassTester {
// Same as app, or using app reference
val jacksonMapper = mapper
val kotlinxMapper = json
@Test
fun isIdenticalSerialization() {
val serializableClasses = findSerializableClasses("com.lagradost")
println("Number of serializable classes: ${serializableClasses.size}")
val failures = mutableListOf<String>()
serializableClasses.forEach { kClass ->
runCatching {
val instance = Instancio.of(kClass.java).withMaxDepth(10).create()
val jacksonJson = jacksonMapper.writeValueAsString(instance)
val kotlinxJson = serializeWithKotlinx(kClass, instance)
assertEquals(
jacksonJson,
kotlinxJson,
"""
Serialization mismatch for:
${kClass.qualifiedName}
Jackson:
$jacksonJson
Kotlinx:
$kotlinxJson
""".trimIndent()
)
println("Identical serialization for: ${kClass.jvmName}")
}.onFailure { e ->
failures.add("FAILED ${kClass.qualifiedName}: ${e.message}")
}
}
if (failures.isNotEmpty()) {
throw AssertionError("${failures.size} class(es) failed:\n${failures.joinToString("\n")}")
}
}
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
@Test
fun isIdenticalDeserialization() {
val serializableClasses = findSerializableClasses("com.lagradost")
println("Number of serializable classes: ${serializableClasses.size}")
val failures = mutableListOf<String>()
serializableClasses.forEach { kClass ->
runCatching {
val instance = Instancio.of(kClass.java).withMaxDepth(10).create()
// Convert to JSON to get example JSON object
// We prefer jackson here because the app may have many jackson JSON strings in local storage
val originalJson = jacksonMapper.writeValueAsString(instance)
// Create an object from the JSON using kotlinx
val serializer =
kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass)
assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!")
val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson)
// Create an object from the JSON using jackson
val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java)
// Deep inspect both object using the mapper toJson function.
// This deep equality check can be performed using other methods, but this just works.
val jacksonJson = mapperDecoded.toJson()
val kotlinxJson = kotlinxDecoded.toJson()
assertEquals(
jacksonJson,
kotlinxJson,
"""
Serialization mismatch for:
${kClass.qualifiedName}
Jackson:
$jacksonJson
Kotlinx:
$kotlinxJson
""".trimIndent()
)
println("Identical deserialization for: ${kClass.jvmName}")
}.onFailure { e ->
failures.add("FAILED ${kClass.qualifiedName}: ${e.message}")
}
}
if (failures.isNotEmpty()) {
throw AssertionError("${failures.size} class(es) failed:\n${failures.joinToString("\n")}")
}
}
// DEX files are the best solution to read all our classes dynamically.
// classgraph could be used instead, but it only gives results on the JVM, not Android.
@Suppress("DEPRECATION")
private fun findSerializableClasses(packageName: String): List<KClass<*>> {
val context = InstrumentationRegistry
.getInstrumentation()
.targetContext
val dexFile = DexFile(context.packageCodePath)
return dexFile.entries()
.toList()
.filter { it.startsWith(packageName) }
.mapNotNull {
runCatching { Class.forName(it).kotlin }.getOrNull()
}.filter { kClass ->
// Not possible to use .hasAnnotation() on newer Android versions.
kClass.java.annotations.any { it is Serializable }
&& kClass.java.annotations.none { it is SkipSerializationTest }
&& !kClass.isAbstract
}
}
@OptIn(InternalSerializationApi::class)
@Suppress("UNCHECKED_CAST")
private fun serializeWithKotlinx(
kClass: KClass<*>,
value: Any
): String {
val serializer = kClass.serializer() as KSerializer<Any>
return kotlinxMapper.encodeToString(serializer, value)
}
}

View file

@ -1,41 +0,0 @@
package com.lagradost.cloudstream3.utils.serializers
import android.net.Uri
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@Serializable
data class UriData(
@Serializable(with = UriSerializer::class)
@SerialName("uri") val uri: Uri = Uri.EMPTY,
)
class UriSerializerTest {
@Test
fun uriSerializerSerializesUriToString() {
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
val result = data.toJson()
assertTrue(result.contains("https://example.com/path?query=1"))
}
@Test
fun uriSerializerDeserializesStringToUri() {
val input = """{"uri":"https://example.com/path?query=1"}"""
val result = parseJson<UriData>(input)
assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri)
}
@Test
fun uriSerializerRoundtripsCorrectly() {
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
val encoded = data.toJson()
val decoded = parseJson<UriData>(encoded)
assertEquals(data.uri, decoded.uri)
}
}

View file

@ -16,53 +16,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <!-- We can use this directly as CS3 is not on Play Store --> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <!-- We can use this directly as CS3 is not on Play Store -->
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA" /> <!-- We can use to read the tv channel list -->
<!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages --> <!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages -->
<uses-permission <uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES" android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
<queries>
<!--
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
-->
<!-- For external video players -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/x-mpegURL" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/vnd.apple.mpegurl" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="magnet" />
</intent>
<!-- Common players supported in actions/temp -->
<package android:name="org.videolan.vlc" />
<package android:name="org.videolan.vlc.debug" />
<package android:name="is.xyz.mpv" />
<package android:name="is.xyz.mpv.ytdl" />
<package android:name="app.marlboroadvance.mpvex" />
<package android:name="live.mehiz.mpvkt" />
<package android:name="live.mehiz.mpvkt.preview" />
<package android:name="com.brouken.player" />
<package android:name="dev.anilbeesetti.nextplayer" />
<package android:name="com.instantbits.cast.webvideo" />
<package android:name="com.gianlu.aria2android" />
<!-- Torrent clients -->
<package android:name="org.proninyaroslav.libretorrent" />
<package android:name="com.biglybt.android.client" />
</queries>
<!-- Fixes android tv fuckery --> <!-- Fixes android tv fuckery -->
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
@ -74,8 +33,9 @@
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. --> <!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android--> <!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
<application <application
android:name=".CloudStreamApp" android:name=".AcraApplication"
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:appCategory="video" android:appCategory="video"
android:banner="@mipmap/ic_banner" android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/backup_descriptor" android:fullBackupContent="@xml/backup_descriptor"
@ -83,12 +43,11 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:pageSizeCompat="enabled"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="${target_sdk_version}"> tools:targetApi="35">
<meta-data <meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
@ -149,31 +108,14 @@
android:launchMode="singleTask" android:launchMode="singleTask"
is a bit experimental, it makes loading repositories from browser still stay on the same page is a bit experimental, it makes loading repositories from browser still stay on the same page
no idea about side effects no idea about side effects
Not exported to prevent bypassing the AccountSelectActivity
--> -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:exported="false" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:resizeableActivity="true" android:resizeableActivity="true"
android:supportsPictureInPicture="true" /> android:supportsPictureInPicture="true">
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- cloudstreamplayer://encodedUrl?name=Dune --> <!-- cloudstreamplayer://encodedUrl?name=Dune -->
<intent-filter> <intent-filter>
@ -200,14 +142,7 @@
<data android:scheme="cloudstreamrepo" /> <data android:scheme="cloudstreamrepo" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="csshare" />
</intent-filter>
<!-- Allow searching with intents: cloudstreamsearch://Your%20Name --> <!-- Allow searching with intents: cloudstreamsearch://Your%20Name -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -231,7 +166,7 @@
<data android:scheme="cloudstreamcontinuewatching" /> <data android:scheme="cloudstreamcontinuewatching" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="false"> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -244,6 +179,25 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.EasterEggMonke"
android:exported="true" />
<receiver <receiver
android:name=".receivers.VideoDownloadRestartReceiver" android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false" android:enabled="false"
@ -259,12 +213,6 @@
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:exported="false" /> android:exported="false" />
<service
android:name=".services.DownloadQueueService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 --> <!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"

View file

@ -0,0 +1,28 @@
#include <jni.h>
#include <csignal>
#include <android/log.h>
#define TAG "CloudStream Crash Handler"
volatile sig_atomic_t gSignalStatus = 0;
void handleNativeCrash(int signal) {
gSignalStatus = signal;
}
extern "C" JNIEXPORT void JNICALL
Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
#define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
REGISTER_SIGNAL(SIGSEGV)
#undef REGISTER_SIGNAL
}
//extern "C" JNIEXPORT void JNICALL
//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
// int *p = nullptr;
// *p = 0;
//}
extern "C" JNIEXPORT int JNICALL
Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
//__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
return gSignalStatus;
}

View file

@ -1,78 +1,233 @@
package com.lagradost.cloudstream3 package com.lagradost.cloudstream3
/** import android.app.Activity
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins. import android.app.Application
* Use CloudStreamApp instead. import android.content.Context
*/ import android.content.ContextWrapper
@Deprecated( import android.content.Intent
message = "AcraApplication is deprecated, use CloudStreamApp instead", import android.widget.Toast
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), import androidx.fragment.app.Fragment
level = DeprecationLevel.ERROR import androidx.fragment.app.FragmentActivity
) import coil3.PlatformContext
class AcraApplication { import coil3.SingletonImageLoader
companion object { import com.lagradost.api.setContext
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
import com.lagradost.cloudstream3.utils.DataStore.setKey
import com.lagradost.cloudstream3.utils.ImageLoader
import kotlinx.coroutines.runBlocking
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.CoreConfiguration
import org.acra.data.CrashReportData
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.ReportSender
import org.acra.sender.ReportSenderFactory
import java.io.File
import java.io.FileNotFoundException
import java.io.PrintStream
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.system.exitProcess
@Deprecated( class CustomReportSender : ReportSender {
message = "AcraApplication is deprecated, use CloudStreamApp instead", // Sends all your crashes to google forms
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), override fun send(context: Context, errorContent: CrashReportData) {
level = DeprecationLevel.ERROR /*println("Sending report")
) val url =
val context get() = CloudStreamApp.context "https://docs.google.com/forms/d/e/$id/formResponse"
val data = mapOf(
"entry.$entry" to errorContent.toJSON()
)
@Deprecated( thread { // to not run it on main thread
message = "AcraApplication is deprecated, use CloudStreamApp instead", runBlocking {
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), suspendSafeApiCall {
level = DeprecationLevel.ERROR app.post(url, data = data)
) //println("Report response: $post")
fun removeKeys(folder: String): Int? = }
CloudStreamApp.removeKeys(folder) }
}
@Deprecated( runOnMainThread { // to run it on main looper
message = "AcraApplication is deprecated, use CloudStreamApp instead", normalSafeApiCall {
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
level = DeprecationLevel.ERROR }
) }*/
fun <T> setKey(path: String, value: T) = }
CloudStreamApp.setKey(path, value)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
level = DeprecationLevel.ERROR
)
fun <T> setKey(folder: String, path: String, value: T) =
CloudStreamApp.setKey(folder, path, value)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
level = DeprecationLevel.ERROR
)
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
CloudStreamApp.getKey(path, defVal)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
level = DeprecationLevel.ERROR
)
inline fun <reified T : Any> getKey(path: String): T? =
CloudStreamApp.getKey(path)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
level = DeprecationLevel.ERROR
)
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
CloudStreamApp.getKey(folder, path)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
level = DeprecationLevel.ERROR
)
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
CloudStreamApp.getKey(folder, path, defVal)
}
} }
class CustomSenderFactory : ReportSenderFactory {
override fun create(context: Context, config: CoreConfiguration): ReportSender {
return CustomReportSender()
}
override fun enabled(config: CoreConfiguration): Boolean {
return true
}
}
class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, error: Throwable) {
ACRA.errorReporter.handleException(error)
try {
PrintStream(errorFile).use { ps ->
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
error.printStackTrace(ps)
}
} catch (ignored: FileNotFoundException) {
}
try {
onError.invoke()
} catch (ignored: Exception) {
}
exitProcess(1)
}
}
class AcraApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
// if we want to initialise coil at earliest
// (maybe when loading an image or gif using in splash screen activity)
//ImageLoader.buildImageLoader(applicationContext)
ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component))
}.also {
exceptionHandler = it
Thread.setDefaultUncaughtExceptionHandler(it)
}
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
context = base
initAcra {
//core configuration:
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
reportContent = listOf(
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
)
// removed this due to bug when starting the app, moved it to when it actually crashes
//each plugin you chose above can be configured in a block like this:
/*toast {
text = getString(R.string.acra_report_toast)
//opening this block automatically enables the plugin.
}*/
}
}
override fun newImageLoader(context: PlatformContext): coil3.ImageLoader {
// Coil Module will be initialized & setSafe globally when first loadImage() is invoked
return ImageLoader.buildImageLoader(applicationContext)
}
companion object {
var exceptionHandler: ExceptionHandler? = null
/** Use to get activity from Context */
tailrec fun Context.getActivity(): Activity? {
return when (this) {
is Activity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
}
}
private var _context: WeakReference<Context>? = null
var context
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
setContext(WeakReference(value))
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
return context?.getKey(path, valueType)
}
fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
}
fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder)
}
fun <T> setKey(path: String, value: T) {
context?.setKey(path, value)
}
fun <T> setKey(folder: String, path: String, value: T) {
context?.setKey(folder, path, value)
}
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
return context?.getKey(path, defVal)
}
inline fun <reified T : Any> getKey(path: String): T? {
return context?.getKey(path)
}
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
return context?.getKey(folder, path)
}
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
return context?.getKey(folder, path, defVal)
}
fun getKeys(folder: String): List<String>? {
return context?.getKeys(folder)
}
fun removeKey(folder: String, path: String) {
context?.removeKey(folder, path)
}
fun removeKey(path: String) {
context?.removeKey(path)
}
/**
* If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails.
* */
fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) {
context?.openBrowser(url, fallbackWebview, fragment)
}
/** Will fallback to webview if in TV layout */
fun openBrowser(url: String, activity: FragmentActivity?) {
openBrowser(
url,
isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull()
)
}
}
}

View file

@ -1,181 +0,0 @@
package com.lagradost.cloudstream3
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.Build
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import com.lagradost.api.setContext
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeAsync
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
import com.lagradost.cloudstream3.utils.AppDebug
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
import com.lagradost.cloudstream3.utils.DataStore.setKey
import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader
import kotlinx.coroutines.runBlocking
import java.io.File
import java.io.FileNotFoundException
import java.io.PrintStream
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.system.exitProcess
class ExceptionHandler(
val errorFile: File,
val onError: (() -> Unit)
) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, error: Throwable) {
try {
val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
thread.threadId()
} else {
@Suppress("DEPRECATION")
thread.id
}
PrintStream(errorFile).use { ps ->
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
ps.println("Fatal exception on thread ${thread.name} ($threadId)")
error.printStackTrace(ps)
}
} catch (_: FileNotFoundException) {
}
try {
onError()
} catch (_: Exception) {
}
exitProcess(1)
}
}
class CloudStreamApp : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
// If we want to initialize Coil as early as possible, maybe when
// loading an image or GIF in a splash screen activity.
// buildImageLoader(applicationContext)
ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component))
}.also {
exceptionHandler = it
Thread.setDefaultUncaughtExceptionHandler(it)
}
AppDebug.isDebug = BuildConfig.DEBUG
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
context = base
}
override fun newImageLoader(context: PlatformContext): ImageLoader {
// Coil module will be initialized globally when first loadImage() is invoked.
return buildImageLoader(applicationContext)
}
companion object {
var exceptionHandler: ExceptionHandler? = null
/** Use to get Activity from Context. */
tailrec fun Context.getActivity(): Activity? {
return when (this) {
is Activity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
}
}
private var _context: WeakReference<Context>? = null
var context
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
setContext(value)
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
return context?.getKey(path, valueType)
}
fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
}
fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder)
}
fun <T> setKey(path: String, value: T) {
context?.setKey(path, value)
}
fun <T> setKey(folder: String, path: String, value: T) {
context?.setKey(folder, path, value)
}
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
return context?.getKey(path, defVal)
}
inline fun <reified T : Any> getKey(path: String): T? {
return context?.getKey(path)
}
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
return context?.getKey(folder, path)
}
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
return context?.getKey(folder, path, defVal)
}
fun getKeys(folder: String): List<String>? {
return context?.getKeys(folder)
}
fun removeKey(folder: String, path: String) {
context?.removeKey(folder, path)
}
fun removeKey(path: String) {
context?.removeKey(path)
}
/** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */
fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) {
context?.openBrowser(url, fallbackWebView, fragment)
}
/** Will fall back to WebView if in TV or emulator layout. */
fun openBrowser(url: String, activity: FragmentActivity?) {
openBrowser(
url,
isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull()
)
}
}
}

View file

@ -1,16 +1,13 @@
package com.lagradost.cloudstream3 package com.lagradost.cloudstream3
import android.annotation.SuppressLint import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.Resources import android.content.res.Resources
import android.Manifest
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
@ -27,41 +24,32 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isNotEmpty
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.CastSession
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
import com.lagradost.cloudstream3.ui.player.Torrent import com.lagradost.cloudstream3.ui.player.Torrent
import com.lagradost.cloudstream3.ui.result.ActorAdaptor import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.ui.result.EpisodeAdapter
import com.lagradost.cloudstream3.ui.result.ImageAdapter
import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.UiText import org.schabi.newpipe.extractor.NewPipe
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.schabi.newpipe.extractor.NewPipe
enum class FocusDirection { enum class FocusDirection {
Start, Start,
@ -101,24 +89,17 @@ object CommonActivity {
get() { get() {
return min(displayMetrics.widthPixels, displayMetrics.heightPixels) return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
} }
val screenWidthWithOrientation: Int
get() {
return displayMetrics.widthPixels
}
val screenHeightWithOrientation: Int
get() {
return displayMetrics.heightPixels
}
var isPipDesired: Boolean = false
var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false
var isInPIPMode: Boolean = false var isInPIPMode: Boolean = false
val onColorSelectedEvent = Event<Pair<Int, Int>>() val onColorSelectedEvent = Event<Pair<Int, Int>>()
val onDialogDismissedEvent = Event<Int>() val onDialogDismissedEvent = Event<Int>()
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
var appliedTheme: Int = 0
var appliedColor: Int = 0
private var currentToast: Toast? = null private var currentToast: Toast? = null
@ -186,40 +167,27 @@ object CommonActivity {
toast.duration = duration ?: Toast.LENGTH_SHORT toast.duration = duration ?: Toast.LENGTH_SHORT
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
toast.view = toast.view = binding.root // FIXME Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
binding.root // FIXME Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
currentToast = toast currentToast = toast
toast.show() toast.show()
val handler = Handler(Looper.getMainLooper())
val ref = WeakReference(toast)
/* Clean up activity leak */
handler.postDelayed({
if (ref.get() == currentToast) {
currentToast = null
}
}, 10_000)
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
} }
/** /**
* Set locale * Not all languages can be fetched from locale with a code.
* @param languageTag shall a IETF BCP 47 conformant tag. * This map allows sidestepping the default Locale(languageCode)
* Check [com.lagradost.cloudstream3.utils.SubtitleHelper]. * when setting the app language.
* **/
* See locales on: val appLanguageExceptions = hashMapOf(
* https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json "zh-rTW" to Locale.TRADITIONAL_CHINESE
* https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry )
* https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml
* https://iso639-3.sil.org/code_tables/639/data/all fun setLocale(context: Context?, languageCode: String?) {
*/ if (context == null || languageCode == null) return
fun setLocale(context: Context?, languageTag: String?) { val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
if (context == null || languageTag == null) return
val locale = Locale.forLanguageTag(languageTag)
val resources: Resources = context.resources val resources: Resources = context.resources
val config = resources.configuration val config = resources.configuration
Locale.setDefault(locale) Locale.setDefault(locale)
@ -227,12 +195,8 @@ object CommonActivity {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
context.createConfigurationContext(config) context.createConfigurationContext(config)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
resources.updateConfiguration( resources.updateConfiguration(config, resources.displayMetrics) // FIXME this should be replaced
config,
resources.displayMetrics
) // FIXME this should be replaced
} }
fun Context.updateLocale() { fun Context.updateLocale() {
@ -244,26 +208,30 @@ object CommonActivity {
fun init(act: Activity) { fun init(act: Activity) {
setActivityInstance(act) setActivityInstance(act)
ioSafe { Torrent.deleteAllFiles() } ioSafe { Torrent.deleteAllFiles() }
val componentActivity = activity as? ComponentActivity ?: return val componentActivity = activity as? ComponentActivity ?: return
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
//https://developer.android.com/guide/topics/ui/picture-in-picture
canShowPipMode =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
componentActivity.updateLocale() componentActivity.updateLocale()
componentActivity.updateTv() componentActivity.updateTv()
AccountManager.initMainAPI()
NewPipe.init(DownloaderTestImpl.getInstance()) NewPipe.init(DownloaderTestImpl.getInstance())
MainActivity.activityResultLauncher = MainActivity.activityResultLauncher = componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == AppCompatActivity.RESULT_OK) {
if (result.resultCode == AppCompatActivity.RESULT_OK) { val actionUid = getKey<String>("last_click_action") ?: return@registerForActivityResult
val actionUid = Log.d(TAG, "Loading action $actionUid result handler")
getKey<String>("last_click_action") ?: return@registerForActivityResult val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction ?: return@registerForActivityResult
Log.d(TAG, "Loading action $actionUid result handler") action.onResultSafe(act, result.data)
val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction removeKey("last_click_action")
?: return@registerForActivityResult removeKey("last_opened_id")
action.onResultSafe(act, result.data)
removeKey("last_click_action")
removeKey("last_opened")
}
} }
}
// Ask for notification permissions on Android 13 // Ask for notification permissions on Android 13
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
@ -283,15 +251,13 @@ object CommonActivity {
} }
} }
/** Enters pip mode if it is both possible and desired to do so*/
private fun Activity.enterPIPMode() { private fun Activity.enterPIPMode() {
if (!isPipDesired || !this.isPIPPossible()) return if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try { try {
enterPictureInPictureMode(PictureInPictureParams.Builder().build()) enterPictureInPictureMode(PictureInPictureParams.Builder().build())
} catch (_: Exception) { } catch (e: Exception) {
// Use fallback just in case // Use fallback just in case
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
enterPictureInPictureMode() enterPictureInPictureMode()
@ -307,18 +273,17 @@ object CommonActivity {
} }
} }
fun onUserLeaveHint(act: Activity) { fun onUserLeaveHint(act: Activity?) {
// On Android 12 and later we use setAutoEnterEnabled() instead. if (canEnterPipMode && canShowPipMode) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return act?.enterPIPMode()
act.enterPIPMode() }
} }
fun updateTheme(act: Activity) { fun updateTheme(act: Activity) {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
if (settingsManager if (settingsManager
.getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System" .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
) {
loadThemes(act) loadThemes(act)
} }
} }
@ -350,10 +315,6 @@ object CommonActivity {
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.MonetMode else R.style.AppTheme R.style.MonetMode else R.style.AppTheme
"Dracula" -> R.style.DraculaMode
"Lavender" -> R.style.LavenderMode
"SilentBlue" -> R.style.SilentBlueMode
else -> R.style.AppTheme else -> R.style.AppTheme
} }
@ -386,13 +347,9 @@ object CommonActivity {
else -> R.style.OverlayPrimaryColorNormal else -> R.style.OverlayPrimaryColorNormal
} }
act.theme.applyStyle(currentTheme, true) act.theme.applyStyle(currentTheme, true)
act.theme.applyStyle(currentOverlayTheme, true) act.theme.applyStyle(currentOverlayTheme, true)
appliedTheme = currentTheme
appliedColor = currentOverlayTheme
act.updateTv()
if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true)
act.theme.applyStyle( act.theme.applyStyle(
R.style.LoadedStyle, R.style.LoadedStyle,
true true
@ -423,7 +380,8 @@ object CommonActivity {
private fun View.hasContent(): Boolean { private fun View.hasContent(): Boolean {
return isShown && when (this) { return isShown && when (this) {
is ViewGroup -> this.isNotEmpty() //is RecyclerView -> this.childCount > 0
is ViewGroup -> this.childCount > 0
else -> true else -> true
} }
} }
@ -453,7 +411,7 @@ object CommonActivity {
// if cant focus but visible then break and let android decide // if cant focus but visible then break and let android decide
// the exception if is the view is a parent and has children that wants focus // the exception if is the view is a parent and has children that wants focus
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent -> val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty() parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
} ?: false } ?: false
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
@ -531,8 +489,84 @@ object CommonActivity {
} }
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? { fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
return null
// 149 keycode_numpad 5
when (keyCode) {
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
PlayerEventType.SeekForward
}
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
PlayerEventType.SeekBack
}
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
PlayerEventType.NextEpisode
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
PlayerEventType.PrevEpisode
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
PlayerEventType.Pause
}
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
PlayerEventType.Play
}
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
PlayerEventType.Lock
}
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
PlayerEventType.ToggleHide
}
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
PlayerEventType.ToggleMute
}
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
PlayerEventType.ShowMirrors
}
// OpenSubtitles shortcut
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
PlayerEventType.SearchSubtitlesOnline
}
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
PlayerEventType.ShowSpeed
}
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
PlayerEventType.Resize
}
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
PlayerEventType.SkipOp
}
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
PlayerEventType.SkipCurrentChapter
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
PlayerEventType.PlayPauseToggle
}
else -> null
}?.let { playerEvent ->
playerEventListener?.invoke(playerEvent)
}
//when (keyCode) {
// KeyEvent.KEYCODE_DPAD_CENTER -> {
// println("DPAD PRESSED")
// }
//}
} }
/** overrides focus and custom key events */ /** overrides focus and custom key events */
@ -569,7 +603,6 @@ object CommonActivity {
else -> null else -> null
} }
// println("NEXT FOCUS : $nextView") // println("NEXT FOCUS : $nextView")
if (nextView != null) { if (nextView != null) {
nextView.requestFocus() nextView.requestFocus()
@ -577,15 +610,10 @@ object CommonActivity {
return true return true
} }
// TODO: Figure out why removing the check for SearchAutoComplete seems if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
// to break focus on TV as it shouldn't need to be used.
// Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
// send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
@SuppressLint("RestrictedApi")
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) { ) {
showInputMethod(act.currentFocus?.findFocus()) UIHelper.showInputMethod(act.currentFocus?.findFocus())
} }
//println("Keycode: $keyCode") //println("Keycode: $keyCode")
@ -594,6 +622,7 @@ object CommonActivity {
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG // Toast.LENGTH_LONG
//) //)
} }
// if someone else want to override the focus then don't handle the event as it is already // if someone else want to override the focus then don't handle the event as it is already
@ -603,4 +632,4 @@ object CommonActivity {
} }
return null return null
} }
} }

View file

@ -0,0 +1,11 @@
package com.lagradost.cloudstream3
import android.view.LayoutInflater
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.ui.HeaderViewDecoration
fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) {
val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null)
view.addItemDecoration(HeaderViewDecoration(headerView))
}

File diff suppressed because it is too large Load diff

View file

@ -1,26 +0,0 @@
package com.lagradost.cloudstream3.actions
import android.content.Context
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
class AlwaysAskAction : VideoClickAction() {
override val name = txt(R.string.player_settings_always_ask)
override val isPlayer = true
// Only show in settings, not on a video
override fun shouldShow(context: Context?, video: ResultEpisode?): Boolean = video == null
override suspend fun runAction(
context: Context?,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
// This is handled specially in ResultViewModel2.kt by detecting the AlwaysAskAction
// and showing the player selection dialog instead of executing the action directly
throw NotImplementedError("AlwaysAskAction is handled specially by the calling code")
}
}

View file

@ -6,8 +6,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
@ -21,8 +21,7 @@ import java.io.File
fun updateDurationAndPosition(position: Long, duration: Long) { fun updateDurationAndPosition(position: Long, duration: Long) {
if (position <= 0 || duration <= 0) return if (position <= 0 || duration <= 0) return
val episode = getKey<ResultEpisode>("last_opened") ?: return DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration)
DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
ResultFragment.updateUI() ResultFragment.updateUI()
} }
@ -99,7 +98,7 @@ abstract class OpenInAppAction(
intent.component = ComponentName(packageName, intentClass) intent.component = ComponentName(packageName, intentClass)
} }
putExtra(context, intent, video, result, index) putExtra(context, intent, video, result, index)
setKey("last_opened", video) setKey("last_opened_id", video.id)
launchResult(intent) launchResult(intent)
} }

View file

@ -12,63 +12,44 @@ import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage
import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction
import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage
import com.lagradost.cloudstream3.actions.temp.MpvExPackage
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
import com.lagradost.cloudstream3.actions.temp.MpvPackage import com.lagradost.cloudstream3.actions.temp.MpvPackage
import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
import com.lagradost.cloudstream3.actions.temp.VlcPackage import com.lagradost.cloudstream3.actions.temp.VlcPackage
import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage
import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.Callable import java.util.concurrent.Callable
import java.util.concurrent.FutureTask import java.util.concurrent.FutureTask
import kotlin.reflect.jvm.jvmName
object VideoClickActionHolder { object VideoClickActionHolder {
val allVideoClickActions = atomicListOf( val allVideoClickActions = threadSafeListOf(
// Default // Default
PlayInBrowserAction(), PlayInBrowserAction(),
CopyClipboardAction(), CopyClipboardAction(),
ViewM3U8Action(), ViewM3U8Action(),
PlayMirrorAction(),
// main support external apps // main support external apps
VlcPackage(), VlcPackage(),
MpvPackage(), MpvPackage(),
MpvExPackage(),
NextPlayerPackage(),
JustPlayerPackage(),
FcastAction(), FcastAction(),
LibreTorrentPackage(),
BiglyBTPackage(),
// forks/backup apps // forks/backup apps
VlcNightlyPackage(),
WebVideoCastPackage(), WebVideoCastPackage(),
MpvYTDLPackage(), MpvYTDLPackage(),
MpvKtPackage(), MpvKtPackage(),
MpvKtPreviewPackage(), MpvKtPreviewPackage(),
OnlyPlayer(),
MpvRxPackage(),
// Always Ask option
AlwaysAskAction(),
// added by plugins // added by plugins
// ... // ...
) )
@ -160,7 +141,7 @@ abstract class VideoClickAction {
} }
} }
fun uniqueId() = "$sourcePlugin:${this::class.qualifiedName}" fun uniqueId() = "$sourcePlugin:${this::class.jvmName}"
@Throws @Throws
abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean
@ -201,4 +182,4 @@ abstract class VideoClickAction {
CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG) CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG)
} }
} }
} }

View file

@ -1,30 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/devgianlu/Aria2Android */
@Suppress("unused")
class Aria2Package : OpenInAppAction(
appName = txt("Aria2"),
packageName = "com.gianlu.aria2android",
intentClass = "com.gianlu.aria2android.MainActivity"
) {
override val oneSource: Boolean = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
throw NotImplementedError("Aria2Android is missing getIntent, and onNewIntent, meaning it cant handle intents")
}
override fun onResult(activity: Activity, intent: Intent?) = Unit
}

View file

@ -1,36 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/BiglySoftware/BiglyBT-Android */
class BiglyBTPackage : OpenInAppAction(
appName = txt("BiglyBT"),
packageName = "com.biglybt.android.client",
intentClass = "com.biglybt.android.client.activity.IntentHandler"
) {
// Only torrents are supported by the app
override val sourceTypes: Set<ExtractorLinkType> =
setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT)
override val oneSource: Boolean = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
intent.data = result.links[index!!].url.toUri()
}
override fun onResult(activity: Activity, intent: Intent?) = Unit
}

View file

@ -1,160 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.SkipSerializationTest
import com.lagradost.cloudstream3.ui.player.ExtractorUri
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.DrmExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.newExtractorLink
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
import com.lagradost.cloudstream3.utils.serializers.UriSerializer
import com.lagradost.cloudstream3.utils.txt
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* If you want to support CloudStream 3 as an external player, then this shows how to play any video link
* For basic interactions, just `intent.data = uri` works
*
* However for more advanced use, CloudStream 3 also supports playlists of MinimalVideoLink and MinimalSubtitleLink with a `String[]` of JSON
* These are passed as LINKS_EXTRA and SUBTITLE_EXTRA respectively
*/
@Suppress("Unused")
class CloudStreamPackage : OpenInAppAction(
appName = txt("CloudStream"),
packageName = BuildConfig.APPLICATION_ID, //"com.lagradost.cloudstream3" or "com.lagradost.cloudstream3.prerelease"
intentClass = "com.lagradost.cloudstream3.ui.player.DownloadedPlayerActivity"
) {
override val oneSource: Boolean = false
companion object {
const val SUBTITLE_EXTRA: String = "subs" // Json of an array of MinimalVideoLink
const val LINKS_EXTRA: String = "links" // Json of an array of MinimalSubtitleLink
const val TITLE_EXTRA: String = "title" // Unused (String)
const val ID_EXTRA: String =
"id" // Identification number for the video(s), used to store start time (Int)
const val POSITION_EXTRA: String = "pos" // Start time in MS (Long)
const val DURATION_EXTRA: String = "dur" // Duration time in MS (Long)
}
@Serializable
@SkipSerializationTest //.Uri has issues with Jackson
data class MinimalVideoLink(
@JsonProperty("uri") @SerialName("uri")
@Serializable(with = UriSerializer::class)
val uri: Uri?,
@JsonProperty("url") @SerialName("url") val url: String?,
@JsonProperty("mimeType") @SerialName("mimeType") val mimeType: String = "video/mp4",
@JsonProperty("name") @SerialName("name") val name: String?,
@JsonProperty("headers") @SerialName("headers") var headers: Map<String, String> = mapOf(),
@JsonProperty("quality") @SerialName("quality") val quality: Int?,
) {
companion object {
fun fromExtractor(link: ExtractorLink): MinimalVideoLink = MinimalVideoLink(
uri = null,
url = link.url,
name = link.name,
mimeType = link.type.getMimeType(),
headers = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) + link.headers,
quality = link.quality
)
}
suspend fun toExtractorLink(): Pair<ExtractorLink?, ExtractorUri?> =
url?.let { url ->
newExtractorLink(
source = "NONE",
name = name ?: "Unknown",
url = url,
type = ExtractorLinkType.entries.firstOrNull { ty -> ty.getMimeType() == mimeType }
?: ExtractorLinkType.VIDEO) {
this@newExtractorLink.headers =
this@MinimalVideoLink.headers
this@newExtractorLink.quality =
this@MinimalVideoLink.quality ?: Qualities.Unknown.value
}
} to uri?.let { uri ->
ExtractorUri(
uri = uri,
name = name ?: "Unknown",
)
}
}
@Serializable
data class MinimalSubtitleLink(
@JsonProperty("url") @SerialName("url") val url: String,
@JsonProperty("mimeType") @SerialName("mimeType") val mimeType: String = "text/vtt",
@JsonProperty("name") @SerialName("name") val name: String?,
@JsonProperty("headers") @SerialName("headers") var headers: Map<String, String> = mapOf(),
) {
companion object {
fun fromSubtitle(sub: SubtitleData): MinimalSubtitleLink = MinimalSubtitleLink(
url = sub.url,
mimeType = sub.mimeType,
name = sub.originalName,
headers = sub.headers,
)
}
fun toSubtitleData(): SubtitleData = SubtitleData(
url = url,
nameSuffix = "",
mimeType = mimeType,
originalName = name ?: "Unknown",
headers = headers,
origin = SubtitleOrigin.URL,
languageCode = fromCodeToLangTagIETF(name) ?:
fromLanguageToTagIETF(name, true) ?:
name,
)
}
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
intent.apply {
val position = getViewPos(video.id)?.position
if (position != null)
putExtra(POSITION_EXTRA, position)
putExtra(ID_EXTRA, video.id)
putExtra(TITLE_EXTRA, video.name)
putExtra(
SUBTITLE_EXTRA,
result.subs.map { MinimalSubtitleLink.fromSubtitle(it).toJson() }.toTypedArray()
)
putExtra(
LINKS_EXTRA,
result.links.filter { it !is ExtractorLinkPlayList && it !is DrmExtractorLink }
.map { MinimalVideoLink.fromExtractor(it).toJson() }.toTypedArray()
)
}
}
override fun onResult(activity: Activity, intent: Intent?) {
// No results yet
}
}

View file

@ -1,37 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/moneytoo/Player/ */
class JustPlayerPackage : OpenInAppAction(
appName = txt("JustPlayer"),
packageName = "com.brouken.player",
intentClass = "com.brouken.player.PlayerActivity"
) {
override val sourceTypes: Set<ExtractorLinkType> =
setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH)
override val oneSource: Boolean = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
// While JustPlayer has support for subs, it cant add both subs and links at the same time
// See https://github.com/moneytoo/Player/blob/49d80eb8de7a7bfc662393fdf114788fed1ebb2e/app/src/main/java/com/brouken/player/PlayerActivity.java#L794
intent.data = result.links[index!!].url.toUri()
}
override fun onResult(activity: Activity, intent: Intent?) = Unit
}

View file

@ -1,36 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/proninyaroslav/libretorrent */
class LibreTorrentPackage : OpenInAppAction(
appName = txt("LibreTorrent"),
packageName = "org.proninyaroslav.libretorrent",
intentClass = "org.proninyaroslav.libretorrent.ui.addtorrent.AddTorrentActivity"
) {
// Only torrents are supported by the app
override val sourceTypes: Set<ExtractorLinkType> =
setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT)
override val oneSource: Boolean = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
intent.data = result.links[index!!].url.toUri()
}
override fun onResult(activity: Activity, intent: Intent?) = Unit
}

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.actions.updateDurationAndPosition
@ -44,7 +45,7 @@ open class MpvKtPackage(
intent.apply { intent.apply {
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
setDataAndType(link.url.toUri(), "video/*") setDataAndType(Uri.parse(link.url), "video/*")
// m3u8 plays, but changing sources feature is not available // m3u8 plays, but changing sources feature is not available
// makeTempM3U8Intent(activity, this, result) // makeTempM3U8Intent(activity, this, result)

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.api.Log import com.lagradost.api.Log
import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.OpenInAppAction
@ -17,9 +18,6 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 // https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
// https://mpv-android.github.io/mpv-android/intent.html // https://mpv-android.github.io/mpv-android/intent.html
//https://github.com/marlboro-advance/mpvEx
class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity")
class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
override val sourceTypes = setOf( override val sourceTypes = setOf(
ExtractorLinkType.VIDEO, ExtractorLinkType.VIDEO,
@ -28,10 +26,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
) )
} }
open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction(
txt(appName), txt(appName),
packageName, packageName,
intentClass "is.xyz.mpv.MPVActivity"
) { ) {
override val oneSource = true // mpv has poor playlist support on TV override val oneSource = true // mpv has poor playlist support on TV
override suspend fun putExtra( override suspend fun putExtra(
@ -46,7 +44,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv
putExtra("title", video.name) putExtra("title", video.name)
if (index != null) { if (index != null) {
setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*")
} else { } else {
makeTempM3U8Intent(context, this, result) makeTempM3U8Intent(context, this, result)
} }

View file

@ -1,75 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.lagradost.api.Log
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/Riteshp2001/mpvRx
*
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56
* */
class MpvRxPackage : OpenInAppAction(
appName = txt("mpvRx"),
packageName = "app.gyrolet.mpvrx",
intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity"
) {
override val oneSource = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
intent.apply {
putExtra("title", video.name)
val link = result.links[index!!]
val headers = link.headers
setData(link.url.toUri())
if (headers.isNotEmpty()) {
// PlayerActivity expects a flat array: [key1, value1, key2, value2, ...]
val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray()
intent.putExtra("headers", flat)
}
/*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146
intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray())
intent.putExtra(
"subs.titles",
subs.map { it.name }.toTypedArray(),
)
intent.putExtra(
"subs.langs",
subs.map { it.languageCode }.toTypedArray(),
)
val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri()
intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf<Uri>() )*/
if (video.tvType.isEpisodeBased()) {
video.season?.let { intent.putExtra("introdb_season", it) }
video.episode.let { intent.putExtra("introdb_episode", it) }
}
val position = getViewPos(video.id)?.position
if (position != null)
putExtra("position", position.toInt())
}
}
override fun onResult(activity: Activity, intent: Intent?) {
val position = intent?.getIntExtra("position", -1) ?: -1
val duration = intent?.getIntExtra("duration", -1) ?: -1
Log.d("MPV", "Position: $position, Duration: $duration")
updateDurationAndPosition(position.toLong(), duration.toLong())
}
}

View file

@ -1,35 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/anilbeesetti/nextplayer */
class NextPlayerPackage : OpenInAppAction(
appName = txt("NextPlayer"),
packageName = "dev.anilbeesetti.nextplayer",
intentClass = "dev.anilbeesetti.nextplayer.feature.player.PlayerActivity"
) {
override val sourceTypes: Set<ExtractorLinkType> =
setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH)
override val oneSource: Boolean = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
intent.data = result.links[index!!].url.toUri()
}
override fun onResult(activity: Activity, intent: Intent?) = Unit
}

View file

@ -1,44 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/Kindness-Kismet/only_player/tree/main
* https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */
class OnlyPlayer : OpenInAppAction(
txt("Only Player"),
"one.only.player",
intentClass = "one.only.player.feature.player.PlayerActivity"
) {
override val oneSource = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
/** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */
intent.apply {
val link = result.links[index!!]
setData(link.url.toUri())
putExtra("headers", Bundle().apply {
for ((key, value) in link.headers) {
putExtra(key, value)
}
})
}
}
override fun onResult(activity: Activity, intent: Intent?) {
/* onResult does not get called */
}
}

View file

@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.net.toUri import android.net.Uri
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() {
) { ) {
val link = result.links.getOrNull(index ?: 0) ?: return val link = result.links.getOrNull(index ?: 0) ?: return
val i = Intent(Intent.ACTION_VIEW) val i = Intent(Intent.ACTION_VIEW)
i.data = link.url.toUri() i.data = Uri.parse(link.url)
launch(i) launch(i)
} }
} }

View file

@ -1,65 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.ui.player.ExtractorUri
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.player.VideoGenerator
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.txt
class PlayMirrorAction : VideoClickAction() {
override val name = txt(R.string.episode_action_play_mirror)
override val oneSource = true
override val isPlayer = true
override val sourceTypes: Set<ExtractorLinkType> = LOADTYPE_INAPP
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
override suspend fun runAction(
context: Context?,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
//Implemented a generator to handle the single
val activity = context as? Activity ?: return
val link = index?.let { result.links[it] }
val generatorMirror = object : VideoGenerator<ResultEpisode>(listOf(video)) {
override val hasCache: Boolean = false
override val canSkipLoading: Boolean = false
override fun getId(index: Int): Int = video.id
override suspend fun generateLinks(
clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit,
offset: Int,
isCasting: Boolean
): Boolean {
index?.let { callback(link to null) }
result.subs.forEach { subtitle -> subtitleCallback(subtitle) }
return true
}
}
activity.navigate(
R.id.global_to_navigation_player,
GeneratorPlayer.newInstance(
generatorMirror, 0, result.syncData
)
)
}
}

View file

@ -6,7 +6,7 @@ import android.content.Intent
import android.os.Build import android.os.Build
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.api.Log import com.lagradost.api.Log
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.actions.updateDurationAndPosition
@ -19,12 +19,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
// https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898 // https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
// https://wiki.videolan.org/Android_Player_Intents/ // https://wiki.videolan.org/Android_Player_Intents/
class VlcNightlyPackage : VlcPackage() { class VlcPackage: OpenInAppAction(
override val packageName = "org.videolan.vlc.debug"
override val appName = txt("VLC Nightly")
}
open class VlcPackage: OpenInAppAction(
appName = txt("VLC"), appName = txt("VLC"),
packageName = "org.videolan.vlc", packageName = "org.videolan.vlc",
intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@ -60,7 +55,7 @@ open class VlcPackage: OpenInAppAction(
intent.putExtra("secure_uri", true) intent.putExtra("secure_uri", true)
intent.putExtra("title", video.name) intent.putExtra("title", video.name)
val subsLang = getKey<String>(SUBTITLE_AUTO_SELECT_KEY) ?: "en" val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en"
result.subs.firstOrNull { result.subs.firstOrNull {
subsLang == it.languageCode subsLang == it.languageCode
}?.let { }?.let {
@ -74,4 +69,4 @@ open class VlcPackage: OpenInAppAction(
Log.d("VLC", "Position: $position, Duration: $duration") Log.d("VLC", "Position: $position, Duration: $duration")
updateDurationAndPosition(position, duration) updateDurationAndPosition(position, duration)
} }
} }

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction(
val link = result.links[index ?: 0] val link = result.links[index ?: 0]
intent.apply { intent.apply {
setDataAndType(link.url.toUri(), "video/*") setDataAndType(Uri.parse(link.url), "video/*")
val title = video.name ?: video.headerName val title = video.name ?: video.headerName

View file

@ -1,7 +1,7 @@
package com.lagradost.cloudstream3.actions.temp.fcast package com.lagradost.cloudstream3.actions.temp.fcast
import android.content.Context import android.content.Context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickAction

View file

@ -7,7 +7,6 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build import android.os.Build
import android.os.ext.SdkExtensions import android.os.ext.SdkExtensions
import android.util.Log import android.util.Log
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
class FcastManager { class FcastManager {
@ -73,66 +72,52 @@ class FcastManager {
} }
override fun onServiceFound(serviceInfo: NsdServiceInfo?) { override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
// Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback if (serviceInfo == null) return
safe {
if (serviceInfo == null) return@safe
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
Build.VERSION_CODES.TIRAMISU Build.VERSION_CODES.TIRAMISU) >= 7) {
) >= 7 nsdManager?.registerServiceInfoCallback(serviceInfo,
) { Runnable::run,
nsdManager?.registerServiceInfoCallback( object : NsdManager.ServiceInfoCallback {
serviceInfo, override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Runnable::run, Log.e(tag, "Service registration failed: $errorCode")
object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.e(tag, "Service registration failed: $errorCode")
}
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.d(
tag,
"Service updated: ${serviceInfo.serviceName}," +
"Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}"
)
synchronized(_currentDevices) {
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
_currentDevices.add(PublicDeviceInfo(serviceInfo))
}
}
override fun onServiceLost() {
Log.d(tag, "Service lost: ${serviceInfo.serviceName},")
synchronized(_currentDevices) {
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
}
}
override fun onServiceInfoCallbackUnregistered() {}
})
} else {
@Suppress("DEPRECATION")
nsdManager?.resolveService(serviceInfo, object : ResolveListener {
override fun onResolveFailed(
serviceInfo: NsdServiceInfo?,
errorCode: Int
) {
} }
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { Log.d(tag,
if (serviceInfo == null) return "Service updated: ${serviceInfo.serviceName}," +
"Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}"
)
synchronized(_currentDevices) { synchronized(_currentDevices) {
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
_currentDevices.add(PublicDeviceInfo(serviceInfo)) _currentDevices.add(PublicDeviceInfo(serviceInfo))
} }
Log.d(
tag,
"Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
)
} }
override fun onServiceLost() {
Log.d(tag, "Service lost: ${serviceInfo.serviceName},")
synchronized(_currentDevices) {
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
}
}
override fun onServiceInfoCallbackUnregistered() {}
}) })
} } else {
@Suppress("DEPRECATION")
nsdManager?.resolveService(serviceInfo, object : ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {}
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
if (serviceInfo == null) return
synchronized(_currentDevices) {
_currentDevices.add(PublicDeviceInfo(serviceInfo))
}
Log.d(
tag,
"Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
)
}
})
} }
} }
@ -183,9 +168,8 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
val host: String? = if ( val host: String? = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
SdkExtensions.getExtensionVersion( SdkExtensions.getExtensionVersion(
Build.VERSION_CODES.TIRAMISU Build.VERSION_CODES.TIRAMISU) >= 7
) >= 7 ) {
) {
serviceInfo.hostAddresses.firstOrNull()?.hostAddress serviceInfo.hostAddresses.firstOrNull()?.hostAddress
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")

View file

@ -1,9 +1,5 @@
package com.lagradost.cloudstream3.actions.temp.fcast package com.lagradost.cloudstream3.actions.temp.fcast
import com.fasterxml.jackson.annotation.JsonProperty
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 // See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1
enum class Opcode(val value: Byte) { enum class Opcode(val value: Byte) {
None(0), None(0),
@ -22,18 +18,18 @@ enum class Opcode(val value: Byte) {
Pong(13); Pong(13);
} }
@Serializable
data class PlayMessage( data class PlayMessage(
@JsonProperty("container") @SerialName("container") val container: String, val container: String,
@JsonProperty("url") @SerialName("url") val url: String? = null, val url: String? = null,
@JsonProperty("content") @SerialName("content") val content: String? = null, val content: String? = null,
@JsonProperty("time") @SerialName("time") val time: Double? = null, val time: Double? = null,
@JsonProperty("speed") @SerialName("speed") val speed: Double? = null, val speed: Double? = null,
@JsonProperty("headers") @SerialName("headers") val headers: Map<String, String>? = null, val headers: Map<String, String>? = null
) )
data class SeekMessage( data class SeekMessage(
val time: Double, val time: Double
) )
data class PlaybackUpdateMessage( data class PlaybackUpdateMessage(
@ -41,26 +37,26 @@ data class PlaybackUpdateMessage(
val time: Double, val time: Double,
val duration: Double, val duration: Double,
val state: Int, val state: Int,
val speed: Double, val speed: Double
) )
data class VolumeUpdateMessage( data class VolumeUpdateMessage(
val generationTime: Long, val generationTime: Long,
val volume: Double, val volume: Double
) )
data class PlaybackErrorMessage( data class PlaybackErrorMessage(
val message: String, val message: String
) )
data class SetSpeedMessage( data class SetSpeedMessage(
val speed: Double, val speed: Double
) )
data class SetVolumeMessage( data class SetVolumeMessage(
val volume: Double, val volume: Double
) )
data class VersionMessage( data class VersionMessage(
val version: Long, val version: Long
) )

View file

@ -1,68 +1,16 @@
package com.lagradost.cloudstream3.mvvm package com.lagradost.cloudstream3.mvvm
import android.view.View
import androidx.activity.ComponentActivity
import androidx.core.view.doOnAttach
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.ui.BaseFragment
/** NOTE: Only one observer at a time per value */ /** NOTE: Only one observer at a time per value */
fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) { fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) }
}
/** NOTE: Only one observer at a time per value */
fun <T> ComponentActivity.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
liveData.removeObservers(this) liveData.removeObservers(this)
liveData.observe(this, action) liveData.observe(this) { it?.let { t -> action(t) } }
} }
/** NOTE: Only one observer at a time per value */ /** NOTE: Only one observer at a time per value */
fun <T, V : ViewBinding> BaseFragment<V>.observe(liveData: LiveData<T>, action: (T) -> Unit) { fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) } liveData.removeObservers(this)
liveData.observe(this) { action(it) }
} }
/**
* Attaches an observable to the root binding, instead of the fragment. This is more efficient as
* it will not call observe if the view is in the background.
*
* NOTE: Only one observer at a time per value
* */
fun <T, V : ViewBinding> BaseFragment<V>.observeNullable(
liveData: LiveData<T>, action: (T?) -> Unit
) {
val root = this.binding?.root
if (root == null) {
liveData.removeObservers(this)
liveData.observe(this, action)
} else {
root.doOnAttach { view ->
// On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case
val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable
liveData.removeObservers(owner)
liveData.observe(owner, action)
}
}
}
/** NOTE: Only one observer at a time per value */
fun <T> View.observe(liveData: LiveData<T>, action: (T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) }
}
/** NOTE: Only one observer at a time per value */
fun <T> View.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
doOnAttach { view ->
// On attach should make findViewTreeLifecycleOwner non-null
val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner()
if(owner == null) {
debugException { "Expected non-null findViewTreeLifecycleOwner" }
return@doOnAttach
}
liveData.removeObservers(owner)
liveData.observe(owner, action)
}
}

View file

@ -5,7 +5,7 @@ import android.webkit.CookieManager
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.debugWarning
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.nicehttp.Requests.Companion.await import com.lagradost.nicehttp.Requests.Companion.await
import com.lagradost.nicehttp.cookies import com.lagradost.nicehttp.cookies
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -32,7 +32,7 @@ class CloudflareKiller : Interceptor {
init { init {
// Needs to clear cookies between sessions to generate new cookies. // Needs to clear cookies between sessions to generate new cookies.
safe { normalSafeApiCall {
// This can throw an exception on unsupported devices :( // This can throw an exception on unsupported devices :(
CookieManager.getInstance().removeAllCookies(null) CookieManager.getInstance().removeAllCookies(null)
} }
@ -77,7 +77,7 @@ class CloudflareKiller : Interceptor {
} }
private fun getWebViewCookie(url: String): String? { private fun getWebViewCookie(url: String): String? {
return safe { return normalSafeApiCall {
CookieManager.getInstance()?.getCookie(url) CookieManager.getInstance()?.getCookie(url)
} }
} }

View file

@ -2,10 +2,9 @@ package com.lagradost.cloudstream3.network
import android.content.Context import android.content.Context
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.Prerelease
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.ignoreAllSSLErrors import com.lagradost.nicehttp.ignoreAllSSLErrors
import okhttp3.Cache import okhttp3.Cache
@ -16,36 +15,19 @@ import org.conscrypt.Conscrypt
import java.io.File import java.io.File
import java.security.Security import java.security.Security
// Backwards compatible constructor, mark as deprecated later
fun Requests.initClient(context: Context) { fun Requests.initClient(context: Context) {
this.baseClient = buildDefaultClient(context) this.baseClient = buildDefaultClient(context)
} }
/** Only use ignoreSSL if you know what you are doing*/
fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) {
this.baseClient = buildDefaultClient(context, ignoreSSL)
}
// Backwards compatible constructor, mark as deprecated later
fun buildDefaultClient(context: Context): OkHttpClient { fun buildDefaultClient(context: Context): OkHttpClient {
return buildDefaultClient(context, false) normalSafeApiCall { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
}
/** Only use ignoreSSL if you know what you are doing*/
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0) val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0)
val baseClient = OkHttpClient.Builder() val baseClient = OkHttpClient.Builder()
.followRedirects(true) .followRedirects(true)
.followSslRedirects(true) .followSslRedirects(true)
.apply { .ignoreAllSSLErrors()
if (ignoreSSL) {
ignoreAllSSLErrors()
}
}
.cache( .cache(
// Note that you need to add a ResponseInterceptor to make this 100% active. // Note that you need to add a ResponseInterceptor to make this 100% active.
// The server response dictates if and when stuff should be cached. // The server response dictates if and when stuff should be cached.
@ -70,6 +52,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
return baseClient return baseClient
} }
//val Request.cookies: Map<String, String>
// get() {
// return this.headers.getCookies("Cookie")
// }
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
/** /**

View file

@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import kotlin.Throws import kotlin.Throws
abstract class Plugin : BasePlugin() { abstract class Plugin : BasePlugin() {
/** /**
* Called when your Plugin is loaded * Called when your Plugin is loaded
@ -25,7 +26,9 @@ abstract class Plugin : BasePlugin() {
fun registerVideoClickAction(element: VideoClickAction) { fun registerVideoClickAction(element: VideoClickAction) {
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
element.sourcePlugin = this.filename element.sourcePlugin = this.filename
VideoClickActionHolder.allVideoClickActions.add(element) synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.add(element)
}
} }
/** /**
@ -37,4 +40,4 @@ abstract class Plugin : BasePlugin() {
* This will add a button in the settings allowing you to add custom settings * This will add a button in the settings allowing you to add custom settings
*/ */
var openSettings: ((context: Context) -> Unit)? = null var openSettings: ((context: Context) -> Unit)? = null
} }

View file

@ -13,7 +13,6 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.annotation.WorkerThread
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@ -21,32 +20,30 @@ import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.removePluginMapping import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.AllLanguagesName
import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.AutoDownloadMode
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.InternalAPI
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.apmap
import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
@ -55,14 +52,12 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import dalvik.system.PathClassLoader import dalvik.system.PathClassLoader
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.io.File import java.io.File
import java.io.InputStreamReader import java.io.InputStreamReader
@ -75,15 +70,13 @@ const val EXTENSIONS_CHANNEL_NAME = "Extensions"
const val EXTENSIONS_CHANNEL_DESCRIPT = "Extension notification channel" const val EXTENSIONS_CHANNEL_DESCRIPT = "Extension notification channel"
// Data class for internal storage // Data class for internal storage
@Serializable
data class PluginData( data class PluginData(
@JsonProperty("internalName") @SerialName("internalName") val internalName: String, @JsonProperty("internalName") val internalName: String,
@JsonProperty("url") @SerialName("url") val url: String?, @JsonProperty("url") val url: String?,
@JsonProperty("isOnline") @SerialName("isOnline") val isOnline: Boolean, @JsonProperty("isOnline") val isOnline: Boolean,
@JsonProperty("filePath") @SerialName("filePath") val filePath: String, @JsonProperty("filePath") val filePath: String,
@JsonProperty("version") @SerialName("version") val version: Int, @JsonProperty("version") val version: Int,
) { ) {
@WorkerThread
fun toSitePlugin(): SitePlugin { fun toSitePlugin(): SitePlugin {
return SitePlugin( return SitePlugin(
this.filePath, this.filePath,
@ -98,9 +91,7 @@ data class PluginData(
null, null,
null, null,
null, null,
File(this.filePath).length(), File(this.filePath).length()
// No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute.
null
) )
} }
} }
@ -154,7 +145,7 @@ object PluginManager {
!it.filePath.contains(repositoryPath) !it.filePath.contains(repositoryPath)
} }
val file = File(repositoryPath) val file = File(repositoryPath)
safe { normalSafeApiCall {
if (file.exists()) file.deleteRecursively() if (file.exists()) file.deleteRecursively()
} }
setKey(PLUGINS_KEY, plugins) setKey(PLUGINS_KEY, plugins)
@ -176,11 +167,11 @@ object PluginManager {
fun getPluginsOnline(): Array<PluginData> { fun getPluginsOnline(): Array<PluginData> {
return getKey<Array<PluginData>>(PLUGINS_KEY) ?: emptyArray() return getKey(PLUGINS_KEY) ?: emptyArray()
} }
fun getPluginsLocal(): Array<PluginData> { fun getPluginsLocal(): Array<PluginData> {
return getKey<Array<PluginData>>(PLUGINS_KEY_LOCAL) ?: emptyArray() return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray()
} }
private val CLOUD_STREAM_FOLDER = private val CLOUD_STREAM_FOLDER =
@ -224,17 +215,17 @@ object PluginManager {
// Helper class for updateAllOnlinePluginsAndLoadThem // Helper class for updateAllOnlinePluginsAndLoadThem
data class OnlinePluginData( data class OnlinePluginData(
val savedData: PluginData, val savedData: PluginData,
val onlineData: PluginWrapper, val onlineData: Pair<String, SitePlugin>,
) { ) {
val isOutdated = val isOutdated =
onlineData.plugin.version > savedData.version || onlineData.plugin.version == PLUGIN_VERSION_ALWAYS_UPDATE onlineData.second.version > savedData.version || onlineData.second.version == PLUGIN_VERSION_ALWAYS_UPDATE
val isDisabled = onlineData.plugin.status == PROVIDER_STATUS_DOWN val isDisabled = onlineData.second.status == PROVIDER_STATUS_DOWN
fun validOnlineData(context: Context): Boolean { fun validOnlineData(context: Context): Boolean {
return getPluginPath( return getPluginPath(
context, context,
savedData.internalName, savedData.internalName,
onlineData.repositoryData.url onlineData.first
).absolutePath == savedData.filePath ).absolutePath == savedData.filePath
} }
} }
@ -264,37 +255,29 @@ object PluginManager {
* 2. If disabled do nothing * 2. If disabled do nothing
* 3. If outdated download and load the plugin * 3. If outdated download and load the plugin
* 4. Else load the plugin normally * 4. Else load the plugin normally
* **/
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. fun updateAllOnlinePluginsAndLoadThem(activity: Activity) {
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
assertNonRecursiveCallstack()
// Load all plugins as fast as possible! // Load all plugins as fast as possible!
___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity) loadAllOnlinePlugins(activity)
afterPluginsLoadedEvent.invoke(false) afterPluginsLoadedEvent.invoke(false)
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY) val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES ?: emptyArray()) + PREBUILT_REPOSITORIES
val onlinePlugins = urls.toList().amap { val onlinePlugins = urls.toList().apmap {
getRepoPlugins(it) ?: emptyList() getRepoPlugins(it.url)?.toList() ?: emptyList()
}.flatten().distinctBy { it.plugin.url } }.flatten().distinctBy { it.second.url }
// Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated // Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated
val outdatedPlugins = getPluginsOnline().map { savedData -> val outdatedPlugins = getPluginsOnline().map { savedData ->
onlinePlugins onlinePlugins
.filter { onlineData -> savedData.internalName == onlineData.plugin.internalName } .filter { onlineData -> savedData.internalName == onlineData.second.internalName }
.map { onlineData -> .map { onlineData ->
OnlinePluginData(savedData, onlineData) OnlinePluginData(savedData, onlineData)
}.filter { }.filter {
it.validOnlineData(activity) it.validOnlineData(activity)
} }
}.flatten().distinctBy { it.onlineData.plugin.url } }.flatten().distinctBy { it.onlineData.second.url }
debugPrint { debugPrint {
"Outdated plugins: ${outdatedPlugins.filter { it.isOutdated }}" "Outdated plugins: ${outdatedPlugins.filter { it.isOutdated }}"
@ -302,21 +285,20 @@ object PluginManager {
val updatedPlugins = mutableListOf<String>() val updatedPlugins = mutableListOf<String>()
outdatedPlugins.amap { pluginData -> outdatedPlugins.apmap { pluginData ->
if (pluginData.isDisabled) { if (pluginData.isDisabled) {
//updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name)) //updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name))
unloadPlugin(pluginData.savedData.filePath) unloadPlugin(pluginData.savedData.filePath)
} else if (pluginData.isOutdated) { } else if (pluginData.isOutdated) {
downloadPlugin( downloadPlugin(
activity, activity,
pluginData.onlineData.plugin.url, pluginData.onlineData.second.url,
pluginData.onlineData.plugin.fileHash,
pluginData.savedData.internalName, pluginData.savedData.internalName,
File(pluginData.savedData.filePath), File(pluginData.savedData.filePath),
true true
).let { success -> ).let { success ->
if (success) if (success)
updatedPlugins.add(pluginData.onlineData.plugin.name) updatedPlugins.add(pluginData.onlineData.second.name)
} }
} }
} }
@ -342,32 +324,21 @@ object PluginManager {
* 1. Gets all online data from online plugins repo * 1. Gets all online data from online plugins repo
* 2. Fetch all not downloaded plugins * 2. Fetch all not downloaded plugins
* 3. Download them and reload plugins * 3. Download them and reload plugins
* **/
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
activity: Activity,
mode: AutoDownloadMode
) {
assertNonRecursiveCallstack()
val newDownloadPlugins = mutableListOf<String>() val newDownloadPlugins = mutableListOf<String>()
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY) val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES ?: emptyArray()) + PREBUILT_REPOSITORIES
val onlinePlugins = urls.toList().amap { val onlinePlugins = urls.toList().apmap {
getRepoPlugins(it)?.toList() ?: emptyList() getRepoPlugins(it.url)?.toList() ?: emptyList()
}.flatten().distinctBy { it.plugin.url } }.flatten().distinctBy { it.second.url }
val providerLang = activity.getApiProviderLangSettings() val providerLang = activity.getApiProviderLangSettings()
//Log.i(TAG, "providerLang => ${providerLang.toJson()}") //Log.i(TAG, "providerLang => ${providerLang.toJson()}")
// Iterate online repos and returns not downloaded plugins // Iterate online repos and returns not downloaded plugins
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData -> val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
val sitePlugin = onlineData.plugin val sitePlugin = onlineData.second
val tvtypes = sitePlugin.tvTypes ?: listOf() val tvtypes = sitePlugin.tvTypes ?: listOf()
//Don't include empty urls //Don't include empty urls
@ -379,7 +350,7 @@ object PluginManager {
} }
//Omit already existing plugins //Omit already existing plugins
if (getPluginPath(activity, sitePlugin.internalName, onlineData.repositoryData.url).exists()) { if (getPluginPath(activity, sitePlugin.internalName, onlineData.first).exists()) {
Log.i(TAG, "Skip > ${sitePlugin.internalName}") Log.i(TAG, "Skip > ${sitePlugin.internalName}")
return@mapNotNull null return@mapNotNull null
} }
@ -418,17 +389,16 @@ object PluginManager {
} }
//Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}") //Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}")
notDownloadedPlugins.amap { pluginData -> notDownloadedPlugins.apmap { pluginData ->
downloadPlugin( downloadPlugin(
activity, activity,
pluginData.onlineData.plugin.url, pluginData.onlineData.second.url,
pluginData.onlineData.plugin.fileHash,
pluginData.savedData.internalName, pluginData.savedData.internalName,
pluginData.onlineData.repositoryData.url, pluginData.onlineData.first,
!pluginData.isDisabled !pluginData.isDisabled
).let { success -> ).let { success ->
if (success) if (success)
newDownloadPlugins.add(pluginData.onlineData.plugin.name) newDownloadPlugins.add(pluginData.onlineData.second.name)
} }
} }
@ -444,27 +414,12 @@ object PluginManager {
Log.i(TAG, "Plugin download done!") Log.i(TAG, "Plugin download done!")
} }
@Throws
private fun assertNonRecursiveCallstack() {
if (Thread.currentThread().stackTrace.any { it.methodName == "loadPlugin" }) {
throw Error("You tried to call a function that will recursively call loadPlugin, this will cause crashes or memory leaks. Do not do this, there is better ways to implement the feature than reloading plugins. Are you sure you read the compile error or docs?")
}
}
/** /**
* Use updateAllOnlinePluginsAndLoadThem * Use updateAllOnlinePluginsAndLoadThem
* * */
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. fun loadAllOnlinePlugins(context: Context) {
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
assertNonRecursiveCallstack()
// Load all plugins as fast as possible! // Load all plugins as fast as possible!
(getPluginsOnline()).toList().amap { pluginData -> (getPluginsOnline()).toList().apmap { pluginData ->
loadPlugin( loadPlugin(
context, context,
File(pluginData.filePath), File(pluginData.filePath),
@ -475,46 +430,27 @@ object PluginManager {
/** /**
* Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb * Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb
* **/
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. fun hotReloadAllLocalPlugins(activity: FragmentActivity?) {
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
assertNonRecursiveCallstack()
Log.d(TAG, "Reloading all local plugins!") Log.d(TAG, "Reloading all local plugins!")
if (activity == null) return if (activity == null) return
getPluginsLocal().forEach { getPluginsLocal().forEach {
unloadPlugin(it.filePath) unloadPlugin(it.filePath)
} }
___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(activity, true) loadAllLocalPlugins(activity, true)
} }
/** /**
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins * @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
* and reload all pages even if they are previously valid * and reload all pages even if they are previously valid
* **/
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
assertNonRecursiveCallstack()
val dir = File(LOCAL_PLUGINS_PATH) val dir = File(LOCAL_PLUGINS_PATH)
if (!dir.exists()) { if (!dir.exists()) {
val res = dir.mkdirs() val res = dir.mkdirs()
if (!res) { if (!res) {
Log.w(TAG, "Failed to create local directories") Log.w(TAG, "Failed to create local directories")
// We have tried to load local plugins, but exit early.
// This needs to be true to prevent the downloader waiting for plugins.
loadedLocalPlugins = true
return return
} }
} }
@ -535,7 +471,7 @@ object PluginManager {
// Make sure all local plugins are fully refreshed. // Make sure all local plugins are fully refreshed.
removeKey(PLUGINS_KEY_LOCAL) removeKey(PLUGINS_KEY_LOCAL)
sortedPlugins?.sortedBy { it.name }?.amap { file -> sortedPlugins?.sortedBy { it.name }?.apmap { file ->
try { try {
val destinationFile = File(pluginDirectory, file.name) val destinationFile = File(pluginDirectory, file.name)
@ -543,8 +479,7 @@ object PluginManager {
// has been modified (check file length and modification time). // has been modified (check file length and modification time).
if (!destinationFile.exists() || if (!destinationFile.exists() ||
destinationFile.length() != file.length() || destinationFile.length() != file.length() ||
destinationFile.lastModified() != file.lastModified() destinationFile.lastModified() != file.lastModified()) {
) {
// Copy the file to the app-specific plugin directory // Copy the file to the app-specific plugin directory
file.copyTo(destinationFile, overwrite = true) file.copyTo(destinationFile, overwrite = true)
@ -567,19 +502,14 @@ object PluginManager {
afterPluginsLoadedEvent.invoke(forceReload) afterPluginsLoadedEvent.invoke(forceReload)
} }
/** @return true if safe mode is enabled in any possible way. */
fun isSafeMode(): Boolean {
return checkSafeModeFile() || lastError != null
}
/** /**
* This can be used to override any extension loading to fix crashes! * This can be used to override any extension loading to fix crashes!
* @return true if safe mode file is present * @return true if safe mode file is present
**/ **/
fun checkSafeModeFile(): Boolean { fun checkSafeModeFile(): Boolean {
return safe { return normalSafeApiCall {
val folder = File(CLOUD_STREAM_FOLDER) val folder = File(CLOUD_STREAM_FOLDER)
if (!folder.exists()) return@safe false if (!folder.exists()) return@normalSafeApiCall false
val files = folder.listFiles { _, name -> val files = folder.listFiles { _, name ->
name.equals("safe", ignoreCase = true) name.equals("safe", ignoreCase = true)
} }
@ -616,7 +546,7 @@ object PluginManager {
return false return false
} }
InputStreamReader(stream).use { reader -> InputStreamReader(stream).use { reader ->
manifest = parseJson<BasePlugin.Manifest>(reader.readText()) manifest = parseJson(reader, BasePlugin.Manifest::class.java)
} }
} }
@ -657,15 +587,9 @@ object PluginManager {
context.resources.configuration context.resources.configuration
) )
} }
synchronized(plugins) { plugins[filePath] = pluginInstance
plugins[filePath] = pluginInstance classLoaders[loader] = pluginInstance
} urlPlugins[data.url ?: filePath] = pluginInstance
synchronized(classLoaders) {
classLoaders[loader] = pluginInstance
}
synchronized(urlPlugins) {
urlPlugins[data.url ?: filePath] = pluginInstance
}
if (pluginInstance is Plugin) { if (pluginInstance is Plugin) {
pluginInstance.load(context) pluginInstance.load(context)
} else { } else {
@ -677,7 +601,7 @@ object PluginManager {
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}") Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
showToast( showToast(
// context.getActivity(), // we are not always on the main thread context.getActivity(),
context.getString(R.string.plugin_load_fail).format(fileName), context.getString(R.string.plugin_load_fail).format(fileName),
Toast.LENGTH_LONG Toast.LENGTH_LONG
) )
@ -701,33 +625,25 @@ object PluginManager {
} }
// remove all registered apis // remove all registered apis
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { synchronized(APIHolder.apis) {
removePluginMapping(it) APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
removePluginMapping(it)
}
}
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
} }
APIHolder.allProviders.withLock { extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
} }
extractorApis.withLock { classLoaders.values.removeIf { v -> v == plugin }
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
}
VideoClickActionHolder.allVideoClickActions.withLock { plugins.remove(absolutePath)
VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename } urlPlugins.values.removeIf { v -> v == plugin }
}
synchronized(classLoaders) {
classLoaders.values.removeIf { v -> v == plugin }
}
synchronized(plugins) {
plugins.remove(absolutePath)
}
synchronized(urlPlugins) {
urlPlugins.values.removeIf { v -> v == plugin }
}
} }
/** /**
@ -757,27 +673,25 @@ object PluginManager {
suspend fun downloadPlugin( suspend fun downloadPlugin(
activity: Activity, activity: Activity,
pluginUrl: String, pluginUrl: String,
pluginHash: String?,
internalName: String, internalName: String,
repositoryUrl: String, repositoryUrl: String,
loadPlugin: Boolean loadPlugin: Boolean
): Boolean { ): Boolean {
val file = getPluginPath(activity, internalName, repositoryUrl) val file = getPluginPath(activity, internalName, repositoryUrl)
return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
} }
suspend fun downloadPlugin( suspend fun downloadPlugin(
activity: Activity, activity: Activity,
pluginUrl: String, pluginUrl: String,
pluginHash: String?,
internalName: String, internalName: String,
file: File, file: File,
loadPlugin: Boolean, loadPlugin: Boolean
): Boolean { ): Boolean {
try { try {
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
val data = PluginData( val data = PluginData(
internalName, internalName,
@ -820,84 +734,6 @@ object PluginManager {
} }
} }
/**
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
assertNonRecursiveCallstack()
showToast(activity.getString(R.string.starting_plugin_update_manually), Toast.LENGTH_LONG)
___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity)
afterPluginsLoadedEvent.invoke(false)
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES
val onlinePlugins = urls.toList().amap {
getRepoPlugins(it) ?: emptyList()
}.flatten().distinctBy { it.plugin.url }
val allPlugins = getPluginsOnline().flatMap { savedData ->
onlinePlugins
.filter { it.plugin.internalName == savedData.internalName }
.mapNotNull { onlineData ->
OnlinePluginData(savedData, onlineData).takeIf { it.validOnlineData(activity) }
}
}.distinctBy { it.onlineData.plugin.url }
val updatedPlugins = mutableListOf<String>()
allPlugins.amap { pluginData ->
if (pluginData.isDisabled) {
Log.e(
"PluginManager",
"Unloading disabled plugin: ${pluginData.onlineData.plugin.name}"
)
unloadPlugin(pluginData.savedData.filePath)
} else {
val existingFile = File(pluginData.savedData.filePath)
if (existingFile.exists()) existingFile.delete()
if (downloadPlugin(
activity,
pluginData.onlineData.plugin.url,
pluginData.onlineData.plugin.fileHash,
pluginData.savedData.internalName,
existingFile,
true
)
) {
updatedPlugins.add(pluginData.onlineData.plugin.name)
}
}
}.also {
main {
val message = if (updatedPlugins.isNotEmpty()) {
activity.getString(R.string.plugins_updated_manually, updatedPlugins.size)
} else {
activity.getString(R.string.no_plugins_updated_manually)
}
showToast(message, Toast.LENGTH_LONG)
val notificationText = UiText.StringResource(
R.string.plugins_updated_manually,
listOf(updatedPlugins.size)
)
createNotification(activity, notificationText, updatedPlugins)
}
}
loadedOnlinePlugins = true
afterPluginsLoadedEvent.invoke(false)
Log.i("PluginManager", "Plugin update done!")
}
private fun Context.createNotificationChannel() { private fun Context.createNotificationChannel() {
hasCreatedNotChanel = true hasCreatedNotChanel = true
// Create the NotificationChannel, but only on API 26+ because // Create the NotificationChannel, but only on API 26+ because
@ -964,4 +800,4 @@ object PluginManager {
return null return null
} }
} }
} }

View file

@ -1,42 +1,37 @@
package com.lagradost.cloudstream3.plugins package com.lagradost.cloudstream3.plugins
import android.content.Context import android.content.Context
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.safeAsync import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName
import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName import java.io.BufferedInputStream
import kotlinx.serialization.Serializable
import java.io.File import java.io.File
import java.nio.file.AtomicMoveNotSupportedException import java.io.InputStream
import java.nio.file.Files import java.io.OutputStream
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/** /**
* Comes with the app, always available in the app, non removable. * Comes with the app, always available in the app, non removable.
*/ * */
@Serializable
data class Repository( data class Repository(
@JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String,
@JsonProperty("name") @SerialName("name") val name: String, @JsonProperty("description") val description: String?,
@JsonProperty("description") @SerialName("description") val description: String?, @JsonProperty("manifestVersion") val manifestVersion: Int,
@JsonProperty("manifestVersion") @SerialName("manifestVersion") val manifestVersion: Int, @JsonProperty("pluginLists") val pluginLists: List<String>
@JsonProperty("pluginLists") @SerialName("pluginLists") val pluginLists: List<String>,
) )
/** /**
@ -45,81 +40,40 @@ data class Repository(
* 1: Ok * 1: Ok
* 2: Slow * 2: Slow
* 3: Beta only * 3: Beta only
*/ * */
@Serializable
data class SitePlugin( data class SitePlugin(
// Url to the .cs3 file // Url to the .cs3 file
@JsonProperty("url") @SerialName("url") val url: String, @JsonProperty("url") val url: String,
// Status to remotely disable the provider // Status to remotely disable the provider
@JsonProperty("status") @SerialName("status") val status: Int, @JsonProperty("status") val status: Int,
// Integer over 0, any change of this will trigger an auto update // Integer over 0, any change of this will trigger an auto update
@JsonProperty("version") @SerialName("version") val version: Int, @JsonProperty("version") val version: Int,
// Unused currently, used to make the api backwards compatible? // Unused currently, used to make the api backwards compatible?
// Set to 1 // Set to 1
@JsonProperty("apiVersion") @SerialName("apiVersion") val apiVersion: Int, @JsonProperty("apiVersion") val apiVersion: Int,
// Name to be shown in app // Name to be shown in app
@JsonProperty("name") @SerialName("name") val name: String, @JsonProperty("name") val name: String,
// Name to be referenced internally. Separate to make name and url changes possible // Name to be referenced internally. Separate to make name and url changes possible
@JsonProperty("internalName") @SerialName("internalName") val internalName: String, @JsonProperty("internalName") val internalName: String,
@JsonProperty("authors") @SerialName("authors") val authors: List<String>, @JsonProperty("authors") val authors: List<String>,
@JsonProperty("description") @SerialName("description") val description: String?, @JsonProperty("description") val description: String?,
// Might be used to go directly to the plugin repo in the future // Might be used to go directly to the plugin repo in the future
@JsonProperty("repositoryUrl") @SerialName("repositoryUrl") val repositoryUrl: String?, @JsonProperty("repositoryUrl") val repositoryUrl: String?,
// These types are yet to be mapped and used, ignore for now // These types are yet to be mapped and used, ignore for now
@JsonProperty("tvTypes") @SerialName("tvTypes") val tvTypes: List<String>?, @JsonProperty("tvTypes") val tvTypes: List<String>?,
// Most often a language tag like "en" or "zh-TW" @JsonProperty("language") val language: String?,
@JsonProperty("language") @SerialName("language") val language: String?, @JsonProperty("iconUrl") val iconUrl: String?,
@JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?,
// Automatically generated by the gradle plugin // Automatically generated by the gradle plugin
@JsonProperty("fileSize") @SerialName("fileSize") val fileSize: Long?, @JsonProperty("fileSize") val fileSize: Long?,
@JsonProperty("fileHash") @SerialName("fileHash") val fileHash: String?,
) )
@Serializable
data class PluginWrapper(
@JsonProperty("repository") @SerialName("repository") val repository: Repository,
@JsonProperty("repositoryData") @SerialName("repositoryData") val repositoryData: RepositoryData,
@JsonProperty("plugin") @SerialName("plugin") val plugin: SitePlugin
) {
companion object {
private val localRepository = Repository("", "", "", 1, emptyList())
private val localRepositoryData = RepositoryData("", "", "")
fun getLocalPluginWrapper(plugin: SitePlugin): PluginWrapper {
return PluginWrapper(
localRepository,
localRepositoryData,
plugin
)
}
}
}
object RepositoryManager { object RepositoryManager {
const val ONLINE_PLUGINS_FOLDER = "Extensions" const val ONLINE_PLUGINS_FOLDER = "Extensions"
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy { val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
getKey<Array<RepositoryData>>("PREBUILT_REPOSITORIES") ?: emptyArray() getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
}
private val GH_REGEX =
Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
/** Returns a SHA-256 string of the file content.
* Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/
@WorkerThread
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { fis ->
val buffer = ByteArray(8192)
var read = fis.read(buffer)
while (read != -1) {
digest.update(buffer, 0, read)
read = fis.read(buffer)
}
}
return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) }
} }
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
fun convertRawGitUrl(url: String): String { fun convertRawGitUrl(url: String): String {
@ -140,37 +94,32 @@ object RepositoryManager {
else fixedUrl else fixedUrl
} }
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
safeAsync { suspendSafeApiCall {
if (fixedUrl.startsWith("!")) { app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
val response = app.get("https://py.md/${fixedUrl.removePrefix("!")}", allowRedirects = false) it2.headers["Location"]?.let { url ->
val url = response.headers["Location"] ?: return@safeAsync null if (url.startsWith("https://cutt.ly/404")) return@suspendSafeApiCall null
if (url.startsWith("https://py.md/404")) return@safeAsync null if (url.removeSuffix("/") == "https://cutt.ly") return@suspendSafeApiCall null
if (url.removeSuffix("/") == "https://py.md") return@safeAsync null return@suspendSafeApiCall url
return@safeAsync url }
} else {
val response = app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false)
val url = response.headers["Location"] ?: return@safeAsync null
if (url.startsWith("https://cutt.ly/404")) return@safeAsync null
if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null
return@safeAsync url
} }
} }
} else null } else null
} }
suspend fun parseRepository(url: String): Repository? { suspend fun parseRepository(url: String): Repository? {
return safeAsync { return suspendSafeApiCall {
// Take manifestVersion and such into account later // Take manifestVersion and such into account later
app.get(convertRawGitUrl(url), cacheTime = 5, cacheUnit = TimeUnit.MINUTES) app.get(convertRawGitUrl(url)).parsedSafe()
.parsedSafe<Repository>()
} }
} }
private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> { private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
// Take manifestVersion and such into account later // Take manifestVersion and such into account later
return try { return try {
app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES) val response = app.get(convertRawGitUrl(pluginUrls))
.parsed<Array<SitePlugin>>().toList() // Normal parsed function not working?
// return response.parsedSafe()
tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
} catch (t: Throwable) { } catch (t: Throwable) {
logError(t) logError(t)
emptyList() emptyList()
@ -179,68 +128,37 @@ object RepositoryManager {
/** /**
* Gets all plugins from repositories and pairs them with the repository url * Gets all plugins from repositories and pairs them with the repository url
*/ * */
suspend fun getRepoPlugins(repositoryData: RepositoryData): List<PluginWrapper>? { suspend fun getRepoPlugins(repositoryUrl: String): List<Pair<String, SitePlugin>>? {
val repo = parseRepository(repositoryData.url) ?: return null val repo = parseRepository(repositoryUrl) ?: return null
val list = repo.pluginLists.amap { url -> return repo.pluginLists.amap { url ->
parsePlugins(url).map { parsePlugins(url).map {
PluginWrapper(repo, repositoryData, it) repositoryUrl to it
} }
}.flatten() }.flatten()
return list
} }
suspend fun downloadPluginToFile( suspend fun downloadPluginToFile(
context: Context,
pluginUrl: String, pluginUrl: String,
file: File, file: File
expectedFileHash: String?
): File? { ): File? {
return safeAsync { return suspendSafeApiCall {
val parentDir = file.parentFile ?: return@safeAsync null file.mkdirs()
parentDir.mkdirs()
// Prevent corrupting the plugin file if the operation fails // Overwrite if exists
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) if (file.exists()) {
file.delete()
}
file.createNewFile()
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
write(body.byteStream(), file.outputStream())
body.byteStream().use { body ->
tempFile.outputStream().use { fileSteam ->
body.copyTo(fileSteam)
}
}
if (expectedFileHash != null) {
val downloadHash = sha256(tempFile)
if (expectedFileHash != downloadHash) {
tempFile.delete()
throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.")
}
}
// We prefer the operation to be atomic
try {
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
file file
} }
} }
fun getRepositories(): Array<RepositoryData> { fun getRepositories(): Array<RepositoryData> {
return getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray() return getKey(REPOSITORIES_KEY) ?: emptyArray()
} }
// Don't want to read before we write in another thread // Don't want to read before we write in another thread
@ -255,7 +173,7 @@ object RepositoryManager {
/** /**
* Also deletes downloaded repository plugins * Also deletes downloaded repository plugins
*/ * */
suspend fun removeRepository(context: Context, repository: RepositoryData) { suspend fun removeRepository(context: Context, repository: RepositoryData) {
val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER) val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER)
@ -273,7 +191,7 @@ object RepositoryManager {
// Unload all plugins, not using deletePlugin since we // Unload all plugins, not using deletePlugin since we
// delete all data and files in deleteRepositoryData // delete all data and files in deleteRepositoryData
safe { normalSafeApiCall {
file.listFiles { plugin: File -> file.listFiles { plugin: File ->
unloadPlugin(plugin.absolutePath) unloadPlugin(plugin.absolutePath)
false false
@ -282,4 +200,13 @@ object RepositoryManager {
PluginManager.deleteRepositoryData(file.absolutePath) PluginManager.deleteRepositoryData(file.absolutePath)
} }
private fun write(stream: InputStream, output: OutputStream) {
val input = BufferedInputStream(stream)
val dataBuffer = ByteArray(512)
var readBytes: Int
while (input.read(dataBuffer).also { readBytes = it } != -1) {
output.write(dataBuffer, 0, readBytes)
}
}
} }

View file

@ -2,88 +2,97 @@ package com.lagradost.cloudstream3.plugins
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import java.security.MessageDigest import java.security.MessageDigest
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
object VotingApi { object VotingApi { // please do not cheat the votes lol
private const val LOGKEY = "VotingApi" private const val LOGKEY = "VotingApi"
private const val API_DOMAIN = "https://api.countify.xyz"
private fun transformUrl(url: String): String = private const val API_DOMAIN = "https://counterapi.com/api"
private fun transformUrl(url: String): String = // dont touch or all votes get reset
MessageDigest MessageDigest
.getInstance("SHA-256") .getInstance("SHA-256")
.digest("${url}#funny-salt".toByteArray()) .digest("${url}#funny-salt".toByteArray())
.fold("") { str, it -> str + "%02x".format(it) } .fold("") { str, it -> str + "%02x".format(it) }
suspend fun SitePlugin.getVotes(): Int = getVotes(url) suspend fun SitePlugin.getVotes(): Int {
fun SitePlugin.hasVoted(): Boolean = hasVoted(url) return getVotes(url)
suspend fun SitePlugin.vote(): Int = vote(url) }
fun SitePlugin.canVote(): Boolean = canVote(this.url)
fun SitePlugin.hasVoted(): Boolean {
return hasVoted(url)
}
suspend fun SitePlugin.vote(): Int {
return vote(url)
}
fun SitePlugin.canVote(): Boolean {
return canVote(this.url)
}
// Plugin url to Int
private val votesCache = mutableMapOf<String, Int>() private val votesCache = mutableMapOf<String, Int>()
private fun getRepository(pluginUrl: String) = pluginUrl
.split("/")
.drop(2)
.take(3)
.joinToString("-")
private suspend fun readVote(pluginUrl: String): Int { private suspend fun readVote(pluginUrl: String): Int {
val id = transformUrl(pluginUrl) val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
val url = "$API_DOMAIN/get-total/$id" Log.d(LOGKEY, "Requesting: $url")
Log.d(LOGKEY, "Requesting GET: $url") return app.get(url).parsedSafe<Result>()?.value ?: 0
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
} }
private suspend fun writeVote(pluginUrl: String): Boolean { private suspend fun writeVote(pluginUrl: String): Boolean {
val id = transformUrl(pluginUrl) val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
val url = "$API_DOMAIN/increment/$id" Log.d(LOGKEY, "Requesting: $url")
Log.d(LOGKEY, "Requesting POST: $url") return app.get(url).parsedSafe<Result>()?.value != null
return app.post(url, emptyMap<String, String>())
.parsedSafe<CountifyResult>()?.count != null
} }
suspend fun getVotes(pluginUrl: String): Int = suspend fun getVotes(pluginUrl: String): Int =
votesCache[pluginUrl] ?: readVote(pluginUrl).also { votesCache[pluginUrl] ?: readVote(pluginUrl).also {
votesCache[pluginUrl] = it votesCache[pluginUrl] = it
} }
fun hasVoted(pluginUrl: String): Boolean = fun hasVoted(pluginUrl: String) =
getKey<Boolean>("cs3-votes/${transformUrl(pluginUrl)}") ?: false getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
fun canVote(pluginUrl: String): Boolean = fun canVote(pluginUrl: String): Boolean {
PluginManager.urlPlugins.contains(pluginUrl) return PluginManager.urlPlugins.contains(pluginUrl)
}
private val voteLock = Mutex() private val voteLock = Mutex()
suspend fun vote(pluginUrl: String): Int { suspend fun vote(pluginUrl: String): Int {
// Prevent multiple requests at the same time.
voteLock.withLock { voteLock.withLock {
if (!canVote(pluginUrl)) { if (!canVote(pluginUrl)) {
main { main {
Toast.makeText( Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
context, .show()
R.string.extension_install_first,
Toast.LENGTH_SHORT
).show()
} }
return getVotes(pluginUrl) return getVotes(pluginUrl)
} }
if (hasVoted(pluginUrl)) { if (hasVoted(pluginUrl)) {
main { main {
Toast.makeText( Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
context, .show()
R.string.already_voted,
Toast.LENGTH_SHORT
).show()
} }
return getVotes(pluginUrl) return getVotes(pluginUrl)
} }
if (writeVote(pluginUrl)) { if (writeVote(pluginUrl)) {
setKey("cs3-votes/${transformUrl(pluginUrl)}", true) setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
@ -93,9 +102,7 @@ object VotingApi {
} }
} }
@Serializable private data class Result(
private data class CountifyResult( val value: Int?
@JsonProperty("id") @SerialName("id") val id: String? = null,
@JsonProperty("count") @SerialName("count") val count: Int? = null,
) )
} }

View file

@ -1,279 +0,0 @@
package com.lagradost.cloudstream3.services
import android.Manifest
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build.VERSION.SDK_INT
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.MainActivity.Companion.setLastError
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.debugWarning
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.system.measureTimeMillis
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class DownloadQueueService : Service() {
companion object {
const val TAG = "DownloadQueueService"
const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue"
const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service"
const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification."
const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique
@Volatile
var isRunning = false
fun getIntent(
context: Context,
): Intent {
return Intent(context, DownloadQueueService::class.java)
}
private val _downloadInstances: MutableStateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
MutableStateFlow(emptyList())
/** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances.
* Completed or failed instances are automatically removed by the download queue service.
*
*/
val downloadInstances: StateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
_downloadInstances
private val totalDownloadFlow =
downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
instances to queue
}
.combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads ->
Triple(instances, queue, currentDownloads)
}
}
private val baseNotification by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent =
PendingIntentCompat.getActivity(this, 0, intent, 0, false)
val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0)
val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0)
NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID)
.setOngoing(true) // Make it persistent
.setAutoCancel(false)
.setColorized(false)
.setOnlyAlertOnce(true)
.setSilent(true)
.setShowWhen(false)
// If low priority then the notification might not show :(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(this.colorFromAttribute(R.attr.colorPrimary))
.setContentText(activeDownloads)
.setSubText(activeQueue)
.setContentIntent(pendingIntent)
.setSmallIcon(R.drawable.download_icon_load)
}
private fun updateNotification(context: Context, downloads: Int, queued: Int) {
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) return
val activeDownloads =
resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads)
val activeQueue =
resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued)
val newNotification = baseNotification
.setContentText(activeDownloads)
.setSubText(activeQueue)
.build()
safe {
NotificationManagerCompat.from(context)
.notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification)
}
}
// We always need to listen to events, even before the download is launched.
// Stopping link loading is an event which can trigger before downloading.
val downloadEventListener = { event: Pair<Int, VideoDownloadManager.DownloadActionType> ->
when (event.second) {
VideoDownloadManager.DownloadActionType.Stop -> {
removeKey(KEY_RESUME_PACKAGES, event.first.toString())
removeKey(KEY_RESUME_IN_QUEUE, event.first.toString())
DownloadQueueManager.cancelDownload(event.first)
}
else -> {}
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
override fun onCreate() {
isRunning = true
val context: Context = this // To make code more readable
Log.d(TAG, "Download queue service started.")
this.createNotificationChannel(
DOWNLOAD_QUEUE_CHANNEL_ID,
DOWNLOAD_QUEUE_CHANNEL_NAME,
DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION
)
if (SDK_INT >= 29) {
startForeground(
DOWNLOAD_QUEUE_NOTIFICATION_ID,
baseNotification.build(),
FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build())
}
downloadEvent += downloadEventListener
val queueJob = ioSafe {
// Ensure this is up to date to prevent race conditions with MainActivity launches
setLastError(context)
// Early return, to prevent waiting for plugins in safe mode
if (lastError != null) return@ioSafe
// Try to ensure all plugins are loaded before starting the downloader.
// To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough
val timeout = 15.seconds
val timeTaken = withTimeoutOrNull(timeout) {
measureTimeMillis {
while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) {
delay(100.milliseconds)
}
}
}
debugWarning({ timeTaken == null || timeTaken > 3_000 }, {
"Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms"
})
debugAssert({ timeTaken == null }, { "Downloader startup should not time out" })
totalDownloadFlow
.debounce { (instances, queue) ->
// Filter away incorrect transient queue states.
// For example when we pop the queue and add a download instance there exists a transient state where
// there is no queue and no download instances (leading to an early exit)
if (instances.isEmpty() && queue.isEmpty()) {
500.milliseconds
} else {
0.milliseconds
}
}
.takeWhile { (instances, queue) ->
// Stop if destroyed
isRunning
// Run as long as there is a queue to process
&& (instances.isNotEmpty() || queue.isNotEmpty())
// Run as long as there are no app crashes
&& lastError == null
}
.collect { (_, queue, currentDownloads) ->
// Remove completed or failed
val newInstances = _downloadInstances.updateAndGet { currentInstances ->
currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled }
}
val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context)
val currentInstanceCount = newInstances.size
val newDownloads = minOf(
// Cannot exceed the max downloads
maxOf(0, maxDownloads - currentInstanceCount),
// Cannot start more downloads than the queue size
queue.size
)
// Cant start multiple downloads at once. If this is rerun it may start too many downloads.
if (newDownloads > 0) {
_downloadInstances.update { instances ->
val downloadInstance = DownloadQueueManager.popQueue(context)
if (downloadInstance != null) {
downloadInstance.startDownload()
instances + downloadInstance
} else {
instances
}
}
}
// The downloads actually displayed to the user with a notification
val currentVisualDownloads =
currentDownloads.size + newInstances.count {
currentDownloads.contains(it.downloadQueueWrapper.id)
.not()
}
// Just the queue
val currentVisualQueue = queue.size
updateNotification(context, currentVisualDownloads, currentVisualQueue)
}
}
// Stop self regardless of job outcome
queueJob.invokeOnCompletion { throwable ->
if (throwable != null) {
logError(throwable)
}
safe {
stopSelf()
}
}
}
override fun onDestroy() {
Log.d(TAG, "Download queue service stopped.")
downloadEvent -= downloadEventListener
isRunning = false
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY // We want the service restarted if its killed
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onTimeout(reason: Int) {
stopSelf()
Log.e(TAG, "Service stopped due to timeout: $reason")
}
}

View file

@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -128,18 +128,18 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
updateProgress(max, progress, true) updateProgress(max, progress, true)
// We need all plugins loaded. // We need all plugins loaded.
PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context) PluginManager.loadAllOnlinePlugins(context)
PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context, false) PluginManager.loadAllLocalPlugins(context, false)
subscriptions.amap { savedData -> subscriptions.apmap { savedData ->
try { try {
val id = savedData.id ?: return@amap null val id = savedData.id ?: return@apmap null
val api = getApiFromNameNull(savedData.apiName) ?: return@amap null val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
// Reasonable timeout to prevent having this worker run forever. // Reasonable timeout to prevent having this worker run forever.
val response = withTimeoutOrNull(60_000) { val response = withTimeoutOrNull(60_000) {
api.load(savedData.url) as? EpisodeResponse api.load(savedData.url) as? EpisodeResponse
} ?: return@amap null } ?: return@apmap null
val dubPreference = val dubPreference =
getDub(id) ?: if ( getDub(id) ?: if (

View file

@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.services
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** Handle notification actions such as pause/resume downloads */
class VideoDownloadService : Service() { class VideoDownloadService : Service() {
private val downloadScope = CoroutineScope(Dispatchers.Default) private val downloadScope = CoroutineScope(Dispatchers.Default)
@ -43,3 +42,19 @@ class VideoDownloadService : Service() {
super.onDestroy() super.onDestroy()
} }
} }
// override fun onHandleIntent(intent: Intent?) {
// if (intent != null) {
// val id = intent.getIntExtra("id", -1)
// val type = intent.getStringExtra("type")
// if (id != -1 && type != null) {
// val state = when (type) {
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
// else -> return
// }
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
// }
// }
// }
//}

View file

@ -1,9 +1,18 @@
package com.lagradost.cloudstream3.subtitles package com.lagradost.cloudstream3.subtitles
import androidx.annotation.WorkerThread
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import okio.BufferedSource import okio.BufferedSource
import okio.buffer import okio.buffer
import okio.sink import okio.sink
@ -11,6 +20,116 @@ import okio.source
import java.io.File import java.io.File
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
interface AbstractSubProvider {
val idPrefix: String
@WorkerThread
@Throws
suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? {
throw NotImplementedError()
}
@WorkerThread
@Throws
suspend fun load(data: SubtitleEntity): String? {
throw NotImplementedError()
}
@WorkerThread
@Throws
suspend fun SubtitleResource.getResources(data: SubtitleEntity) {
this.addUrl(load(data))
}
@WorkerThread
@Throws
suspend fun getResource(data: SubtitleEntity): SubtitleResource {
return SubtitleResource().apply {
this.getResources(data)
}
}
}
class SubRepository(val api: AbstractSubProvider) {
companion object {
data class SavedSearchResponse(
val unixTime: Long,
val response: List<SubtitleEntity>,
val query: SubtitleSearch
)
data class SavedResourceResponse(
val unixTime: Long,
val response: SubtitleResource,
val query: SubtitleEntity
)
// maybe make this a generic struct? right now there is a lot of boilerplate
private val searchCache = threadSafeListOf<SavedSearchResponse>()
private var searchCacheIndex: Int = 0
private val resourceCache = threadSafeListOf<SavedResourceResponse>()
private var resourceCacheIndex: Int = 0
const val CACHE_SIZE = 20
}
val idPrefix: String get() = api.idPrefix
@WorkerThread
suspend fun getResource(data: SubtitleEntity): Resource<SubtitleResource> = safeApiCall {
synchronized(resourceCache) {
for (item in resourceCache) {
// 20 min save
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
return@safeApiCall item.response
}
}
}
val returnValue = api.getResource(data)
synchronized(resourceCache) {
val add = SavedResourceResponse(unixTime, returnValue, data)
if (resourceCache.size > CACHE_SIZE) {
resourceCache[resourceCacheIndex] = add // rolling cache
resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE
} else {
resourceCache.add(add)
}
}
returnValue
}
@WorkerThread
suspend fun search(query: SubtitleSearch): Resource<List<SubtitleEntity>> {
return safeApiCall {
synchronized(searchCache) {
for (item in searchCache) {
// 120 min save
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
return@safeApiCall item.response
}
}
}
val returnValue = api.search(query) ?: throw ErrorLoadingException("Null subtitles")
// only cache valid return values
if (returnValue.isNotEmpty()) {
val add = SavedSearchResponse(unixTime, returnValue, query)
synchronized(searchCache) {
if (searchCache.size > CACHE_SIZE) {
searchCache[searchCacheIndex] = add // rolling cache
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
} else {
searchCache.add(add)
}
}
}
returnValue
}
}
}
/** /**
* A builder for subtitle files. * A builder for subtitle files.
* @see addUrl * @see addUrl
@ -91,3 +210,4 @@ class SubtitleResource {
} }
} }
interface AbstractSubApi : AbstractSubProvider, AuthAPI

View file

@ -1,167 +1,149 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi import com.lagradost.cloudstream3.syncproviders.providers.*
import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import java.util.concurrent.TimeUnit
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
import com.lagradost.cloudstream3.syncproviders.providers.MALApi abstract class AccountManager(private val defIndex: Int) : AuthAPI {
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi companion object {
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi val malApi = MALApi(0).also { api ->
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi LoadResponse.Companion.malIdPrefix = api.idPrefix
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi }
import com.lagradost.cloudstream3.utils.DataStoreHelper val aniListApi = AniListApi(0).also { api ->
import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth LoadResponse.Companion.aniListIdPrefix = api.idPrefix
import java.util.concurrent.TimeUnit }
val simklApi = SimklApi(0).also { api ->
abstract class AccountManager { LoadResponse.Companion.simklIdPrefix = api.idPrefix
companion object { }
const val NONE_ID: Int = -1 val openSubtitlesApi = OpenSubtitlesApi(0)
val malApi = MALApi() val addic7ed = Addic7ed()
val kitsuApi = KitsuApi() val subDlApi = SubDlApi(0)
val aniListApi = AniListApi() val localListApi = LocalList()
val simklApi = SimklApi() val subSourceApi = SubSourceApi()
val localListApi = LocalList()
// used to login via app intent
val openSubtitlesApi = OpenSubtitlesApi() val OAuth2Apis
val addic7ed = Addic7ed() get() = listOf<OAuth2API>(
val subDlApi = SubDlApi() malApi, aniListApi, simklApi
val subSourceApi = SubSourceApi() )
val animeSkipApi = AnimeSkipAuth()
// this needs init with context and can be accessed in settings
var cachedAccounts: MutableMap<String, Array<AuthData>> val accountManagers
var cachedAccountIds: MutableMap<String, Int> get() = listOf(
malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi
const val ACCOUNT_TOKEN = "auth_tokens" )
const val ACCOUNT_IDS = "auth_ids"
// used for active syncing
fun accounts(prefix: String): Array<AuthData> { val SyncApis
require(prefix != "NONE") get() = listOf(
return getKey<Array<AuthData>>( SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
ACCOUNT_TOKEN, )
"${prefix}/${DataStoreHelper.currentAccount}"
) ?: arrayOf() val inAppAuths
} get() = listOf<InAppAuthAPIManager>(
openSubtitlesApi,
fun updateAccounts(prefix: String, array: Array<AuthData>) { subDlApi
require(prefix != "NONE") )//, nginxApi)
setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array)
synchronized(cachedAccounts) { val subtitleProviders
cachedAccounts[prefix] = array get() = listOf(
} openSubtitlesApi,
} addic7ed,
subDlApi,
fun updateAccountsId(prefix: String, id: Int) { subSourceApi
require(prefix != "NONE") )
setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id)
synchronized(cachedAccountIds) { const val APP_STRING = "cloudstreamapp"
cachedAccountIds[prefix] = id const val APP_STRING_REPO = "cloudstreamrepo"
} const val APP_STRING_PLAYER = "cloudstreamplayer"
}
// Instantly start the search given a query
val allApis = arrayOf( const val APP_STRING_SEARCH = "cloudstreamsearch"
SyncRepo(malApi),
SyncRepo(kitsuApi), // Instantly resume watching a show
SyncRepo(aniListApi), const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
SyncRepo(simklApi),
SyncRepo(localListApi), val unixTime: Long
SubtitleRepo(openSubtitlesApi), get() = System.currentTimeMillis() / 1000L
SubtitleRepo(addic7ed), val unixTimeMs: Long
SubtitleRepo(subDlApi), get() = System.currentTimeMillis()
PlainAuthRepo(animeSkipApi),
SubtitleRepo(subSourceApi) const val MAX_STALE = 60 * 10
)
fun secondsToReadable(seconds: Int, completedValue: String): String {
fun updateAccountIds() { var secondsLong = seconds.toLong()
val ids = mutableMapOf<String, Int>() val days = TimeUnit.SECONDS
for (api in allApis) { .toDays(secondsLong)
ids.put( secondsLong -= TimeUnit.DAYS.toSeconds(days)
api.idPrefix,
getKey<Int>( val hours = TimeUnit.SECONDS
ACCOUNT_IDS, .toHours(secondsLong)
"${api.idPrefix}/${DataStoreHelper.currentAccount}", secondsLong -= TimeUnit.HOURS.toSeconds(hours)
NONE_ID
) ?: NONE_ID val minutes = TimeUnit.SECONDS
) .toMinutes(secondsLong)
} secondsLong -= TimeUnit.MINUTES.toSeconds(minutes)
synchronized(cachedAccountIds) { if (minutes < 0) {
cachedAccountIds = ids return completedValue
} }
} //println("$days $hours $minutes")
return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m"
init { }
val data = mutableMapOf<String, Array<AuthData>>() }
val ids = mutableMapOf<String, Int>()
for (api in allApis) { var accountIndex = defIndex
data.put(api.idPrefix, accounts(api.idPrefix)) private var lastAccountIndex = defIndex
ids.put( protected val accountId get() = "${idPrefix}_account_$accountIndex"
api.idPrefix, private val accountActiveKey get() = "${idPrefix}_active"
getKey<Int>(
ACCOUNT_IDS, // int array of all accounts indexes
"${api.idPrefix}/${DataStoreHelper.currentAccount}", private val accountsKey get() = "${idPrefix}_accounts"
NONE_ID
) ?: NONE_ID protected fun removeAccountKeys() {
) removeKeys(accountId)
} val accounts = getAccounts()?.toMutableList() ?: mutableListOf()
cachedAccounts = data accounts.remove(accountIndex)
cachedAccountIds = ids setKey(accountsKey, accounts.toIntArray())
}
init()
// I do not want to place this in the init block as JVM initialization order is weird, and it may cause exceptions }
// accessing other classes
fun initMainAPI() { fun getAccounts(): IntArray? {
LoadResponse.malIdPrefix = malApi.idPrefix return getKey(accountsKey, intArrayOf())
LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix }
LoadResponse.aniListIdPrefix = aniListApi.idPrefix
LoadResponse.simklIdPrefix = simklApi.idPrefix fun init() {
} accountIndex = getKey(accountActiveKey, defIndex)!!
val accounts = getAccounts()
val subtitleProviders = arrayOf( if (accounts?.isNotEmpty() == true && this.loginInfo() == null) {
SubtitleRepo(openSubtitlesApi), accountIndex = accounts.first()
SubtitleRepo(addic7ed), }
SubtitleRepo(subDlApi), }
SubtitleRepo(subSourceApi)
) protected fun switchToNewAccount() {
val syncApis = arrayOf( val accounts = getAccounts()
SyncRepo(malApi), lastAccountIndex = accountIndex
SyncRepo(kitsuApi), accountIndex = (accounts?.maxOrNull() ?: 0) + 1
SyncRepo(aniListApi), }
SyncRepo(simklApi), protected fun switchToOldAccount() {
SyncRepo(localListApi) accountIndex = lastAccountIndex
) }
const val APP_STRING = "cloudstreamapp" protected fun registerAccount() {
const val APP_STRING_REPO = "cloudstreamrepo" setKey(accountActiveKey, accountIndex)
const val APP_STRING_PLAYER = "cloudstreamplayer" val accounts = getAccounts()?.toMutableList() ?: mutableListOf()
if (!accounts.contains(accountIndex)) {
// Instantly start the search given a query accounts.add(accountIndex)
const val APP_STRING_SEARCH = "cloudstreamsearch" }
// Instantly resume watching a show setKey(accountsKey, accounts.toIntArray())
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" }
const val APP_STRING_SHARE = "csshare" fun changeAccount(index: Int) {
accountIndex = index
fun secondsToReadable(seconds: Int, completedValue: String): String { setKey(accountActiveKey, index)
var secondsLong = seconds.toLong() }
val days = TimeUnit.SECONDS }
.toDays(secondsLong)
secondsLong -= TimeUnit.DAYS.toSeconds(days)
val hours = TimeUnit.SECONDS
.toHours(secondsLong)
secondsLong -= TimeUnit.HOURS.toSeconds(hours)
val minutes = TimeUnit.SECONDS
.toMinutes(secondsLong)
secondsLong -= TimeUnit.MINUTES.toSeconds(minutes)
if (minutes < 0) {
return completedValue
}
//println("$days $hours $minutes")
return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m"
}
}
}

View file

@ -1,265 +1,23 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
import com.fasterxml.jackson.annotation.JsonAlias interface AuthAPI {
import com.fasterxml.jackson.annotation.JsonProperty val name: String
import com.lagradost.cloudstream3.APIHolder val icon: Int?
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.base64Encode
import com.lagradost.cloudstream3.splitUrlParameters
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.security.SecureRandom
data class AuthLoginPage( val requiresLogin: Boolean
/** The website to open to authenticate */
val url: String,
/**
* State/control code to verify against the redirectUrl to make sure the request is valid.
* This parameter will be saved, and then used in AuthAPI::login.
*/
val payload: String? = null,
)
@Serializable val createAccountUrl : String?
data class AuthToken(
/**
* This is the general access tokens/api token representing a logged in user.
* Access tokens are the thing that applications use to make API requests on behalf of a user.
*/
@JsonProperty("accessToken") @SerialName("accessToken")
val accessToken: String? = null,
/** For OAuth a special refresh token is issues to refresh the access token. */
@JsonProperty("refreshToken") @SerialName("refreshToken")
val refreshToken: String? = null,
/** In UnixTime (sec) when it expires */
@JsonProperty("accessTokenLifetime") @SerialName("accessTokenLifetime")
val accessTokenLifetime: Long? = null,
/** In UnixTime (sec) when it expires */
@JsonProperty("refreshTokenLifetime") @SerialName("refreshTokenLifetime")
val refreshTokenLifetime: Long? = null,
/**
* Sometimes AuthToken needs to be customized to store e.g. username/password,
* this acts as a catch all to store text or JSON data.
*/
@JsonProperty("payload") @SerialName("payload")
val payload: String? = null,
) {
fun isAccessTokenExpired(marginSec: Long = 10L) =
accessTokenLifetime != null && unixTime + marginSec >= accessTokenLifetime
fun isRefreshTokenExpired(marginSec: Long = 10L) = // don't change this as all keys depend on it
refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime val idPrefix: String
}
@OptIn(ExperimentalSerializationApi::class) // JsonNames is an experimental annotation for now // if this returns null then you are not logged in
@Serializable fun loginInfo(): LoginInfo?
data class AuthUser( fun logOut()
/** Account display-name, can also be email if name does not exist */
@JsonProperty("name") @SerialName("name")
val name: String?,
/**
* Unique account identifier. If a subsequent login is done then it
* will be refused if another account with the same id exists.
*/
@JsonProperty("id") @SerialName("id")
val id: Int,
/** Profile picture URL */
@JsonProperty("profilePicture") @SerialName("profilePicture")
val profilePicture: String? = null,
/** Profile picture Headers of the URL */
@JsonProperty("profilePictureHeaders") @JsonAlias("profilePictureHeader")
@SerialName("profilePictureHeaders") @JsonNames("profilePictureHeader")
val profilePictureHeaders: Map<String, String>? = null,
)
/**
* Stores all information that should be used to authorize access.
* Be aware that token and user may change independently when a refresh is needed,
* and as such there should be no strong pairing between the two.
*
* Any local set/get key should use user.id.toString(),
* as token.accessToken (even hashed) is unsecure, and will rotate.
*/
@Serializable
data class AuthData(
@JsonProperty("user") @SerialName("user") val user: AuthUser,
@JsonProperty("token") @SerialName("token") val token: AuthToken,
)
data class AuthPinData(
val deviceCode: String,
val userCode: String,
/** QR Code url */
val verificationUrl: String,
/** In seconds */
val expiresIn: Int,
/** Check if the code has been verified interval */
val interval: Int,
)
/** The login field requirements to display to the user */
data class AuthLoginRequirement(
val password: Boolean = false,
val username: Boolean = false,
val email: Boolean = false,
val server: Boolean = false,
)
/** What the user responds to the AuthLoginRequirement */
@Serializable
data class AuthLoginResponse(
@JsonProperty("password") @SerialName("password") val password: String?,
@JsonProperty("username") @SerialName("username") val username: String?,
@JsonProperty("email") @SerialName("email") val email: String?,
@JsonProperty("server") @SerialName("server") val server: String?,
)
/** Stateless Authentication class used for all personalized content */
abstract class AuthAPI {
open val name: String = "NONE"
open val idPrefix: String = "NONE"
/** Drawable icon of the service */
open val icon: Int? = null
/** If this service requires an account to use */
open val requiresLogin: Boolean = true
/** Link to a website for creating a new account */
open val createAccountUrl: String? = null
/** The sensitive redirect URL from OAuth should contain "/redirectUrlIdentifier" to trigger the login */
open val redirectUrlIdentifier: String? = null
/** Has OAuth2 login support, including login, loginRequest and refreshToken */
open val hasOAuth2: Boolean = false
/** Has on device pin support, aka login with a QR code */
open val hasPin: Boolean = false
/** Has in app login support, aka login with a dialog */
open val hasInApp: Boolean = false
/** The requirements to login in app */
open val inAppLoginRequirement: AuthLoginRequirement? = null
companion object {
@Deprecated(
message = "Use APIHolder.unixTime instead",
replaceWith = ReplaceWith(
expression = "APIHolder.unixTime",
imports = ["com.lagradost.cloudstream3.APIHolder"]
),
level = DeprecationLevel.WARNING,
)
val unixTime: Long
get() = APIHolder.unixTime
@Deprecated(
message = "Use APIHolder.unixTimeMS instead",
replaceWith = ReplaceWith(
expression = "unixTimeMS",
imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"]
),
level = DeprecationLevel.WARNING,
)
val unixTimeMs: Long
get() = unixTimeMS
fun splitRedirectUrl(redirectUrl: String): Map<String, String> {
return splitUrlParameters(
redirectUrl.replace(APP_STRING, "https").replace("/#", "?")
)
}
fun generateCodeVerifier(): String {
// It is recommended to use a URL-safe string as code_verifier.
// See section 4 of RFC 7636 for more details.
val secureRandom = SecureRandom()
val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128
secureRandom.nextBytes(codeVerifierBytes)
return base64Encode(codeVerifierBytes).trimEnd('=')
.replace("+", "-").replace("/", "_").replace("\n", "")
}
}
/** Is this url a valid redirect url for this service? */
@Throws
open fun isValidRedirectUrl(url: String): Boolean =
redirectUrlIdentifier != null && url.contains("/$redirectUrlIdentifier")
/** OAuth2 login from a valid redirectUrl, and payload given in loginRequest */
@Throws
open suspend fun login(redirectUrl: String, payload: String?): AuthToken? =
throw NotImplementedError()
/** OAuth2 login request, asking the service to provide a url to open in the browser */
@Throws
open fun loginRequest(): AuthLoginPage? = throw NotImplementedError()
/** Pin login request, asking the service to provide an verificationUrl to display with a QR code */
@Throws
open suspend fun pinRequest(): AuthPinData? = throw NotImplementedError()
/** OAuth2 token refresh, this ensures that all token passed to other functions will be valid */
@Throws
open suspend fun refreshToken(token: AuthToken): AuthToken? = throw NotImplementedError()
/** Pin login, this will be called periodically while logging in to check if the pin has been verified by the user */
@Throws
open suspend fun login(payload: AuthPinData): AuthToken? = throw NotImplementedError()
/** In app login */
@Throws
open suspend fun login(form: AuthLoginResponse): AuthToken? = throw NotImplementedError()
/** Get the visible user account */
@Throws
open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError()
/**
* An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid.
*
* Note that this will currently only be called *once* on logout,
* and as such any network issues it will fail silently, and the token will not be revoked.
*/
@Throws
open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError()
@Throws
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
fun toRepo(): AuthRepo = when (this) {
is SubtitleAPI -> SubtitleRepo(this)
is SyncAPI -> SyncRepo(this)
else -> throw NotImplementedError("Unknown inheritance from AuthAPI")
}
@Suppress("DEPRECATION_ERROR")
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
fun loginInfo(): LoginInfo? {
return this.toRepo().authUser()?.let { user ->
LoginInfo(
profilePicture = user.profilePicture,
name = user.name,
accountIndex = -1,
)
}
}
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
@Suppress("DEPRECATION_ERROR")
return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow()
}
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
class LoginInfo( class LoginInfo(
val profilePicture: String? = null, val profilePicture: String? = null,
val name: String?, val name: String?,
val accountIndex: Int, val accountIndex: Int,
) )
} }

View file

@ -1,168 +0,0 @@
package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
import com.lagradost.cloudstream3.utils.txt
/** General-purpose repo */
class PlainAuthRepo(api: AuthAPI) : AuthRepo(api)
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
abstract class AuthRepo(open val api: AuthAPI) {
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false
val idPrefix get() = api.idPrefix
val name get() = api.name
val icon get() = api.icon
val requiresLogin get() = api.requiresLogin
val createAccountUrl get() = api.createAccountUrl
val hasOAuth2 get() = api.hasOAuth2
val hasPin get() = api.hasPin
val hasInApp get() = api.hasInApp
val inAppLoginRequirement get() = api.inAppLoginRequirement
val isAvailable get() = !api.requiresLogin || authUser() != null
companion object {
private val oauthPayload: MutableMap<String, String?> = mutableMapOf()
}
@Throws
protected suspend fun freshAuth(): AuthData? {
val data = authData() ?: return null
if (data.token.isAccessTokenExpired()) {
val newToken = api.refreshToken(data.token) ?: return null
val newAuth = AuthData(user = data.user, token = newToken)
refreshUser(newAuth)
return newAuth
}
return data
}
@Throws
fun openOAuth2Page(): Boolean {
val page = api.loginRequest() ?: return false
synchronized(oauthPayload) {
oauthPayload.put(idPrefix, page.payload)
}
openBrowser(page.url)
return true
}
fun openOAuth2PageWithToast() {
try {
if (!openOAuth2Page()) {
showToast(txt(R.string.authenticated_user_fail, api.name))
}
} catch (t: Throwable) {
logError(t)
if (t is ErrorLoadingException && t.message != null) {
showToast(t.message)
return
}
showToast(txt(R.string.authenticated_user_fail, api.name))
}
}
suspend fun logout(from: AuthUser) {
val currentAccounts = AccountManager.accounts(idPrefix)
val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id }
if (newAccounts.size < currentAccounts.size) {
AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray())
AccountManager.updateAccountsId(idPrefix, 0)
}
for (oldAccount in oldAccounts) {
try {
api.invalidateToken(oldAccount.token)
} catch (_: NotImplementedError) {
// no-op
} catch (t: Throwable) {
logError(t)
}
}
}
fun refreshUser(newAuth: AuthData) {
val currentAccounts = AccountManager.accounts(idPrefix)
val newAccounts = currentAccounts.map {
if (it.user.id == newAuth.user.id) {
newAuth
} else {
it
}
}.toTypedArray()
AccountManager.updateAccounts(idPrefix, newAccounts)
}
fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) {
AccountManager.cachedAccountIds[idPrefix]?.let { id ->
AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id }
}
}
fun authToken(): AuthToken? = authData()?.token
fun authUser(): AuthUser? = authData()?.user
val accounts
get() = synchronized(AccountManager.cachedAccounts) {
AccountManager.cachedAccounts[idPrefix] ?: emptyArray()
}
var accountId
get() = synchronized(AccountManager.cachedAccountIds) {
AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID
}
set(value) {
AccountManager.updateAccountsId(idPrefix, value)
}
@Throws
suspend fun pinRequest() =
api.pinRequest()
@Throws
private suspend fun setupLogin(token: AuthToken): Boolean {
val user = api.user(token) ?: return false
val newAccount = AuthData(
token = token,
user = user,
)
val currentAccounts = AccountManager.accounts(idPrefix)
if (currentAccounts.any { it.user.id == newAccount.user.id }) {
throw ErrorLoadingException("Already logged into this account")
}
val newAccounts = currentAccounts + newAccount
AccountManager.updateAccounts(idPrefix, newAccounts)
AccountManager.updateAccountsId(idPrefix, user.id)
if (this is SyncRepo) {
requireLibraryRefresh = true
}
return true
}
@Throws
suspend fun login(form: AuthLoginResponse): Boolean {
return setupLogin(api.login(form) ?: return false)
}
@Throws
suspend fun login(payload: AuthPinData): Boolean {
return setupLogin(api.login(payload) ?: return false)
}
@Throws
suspend fun login(redirectUrl: String): Boolean {
return setupLogin(
api.login(
redirectUrl,
synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false
)
}
}

View file

@ -1,14 +0,0 @@
package com.lagradost.cloudstream3.syncproviders
/** Work in progress */
abstract class BackupAPI : AuthAPI() {
open val filename : String = "cloudstream-backup.json"
/** Get the backup file as a JSON string from the remote storage. Return null if not found/empty */
@Throws
open suspend fun downloadFile(auth: AuthData?) : String? = throw NotImplementedError()
/** Get the backup file as a JSON string from the remote storage. */
@Throws
open suspend fun uploadFile(auth: AuthData?, data : String) : String? = throw NotImplementedError()
}

View file

@ -0,0 +1,66 @@
package com.lagradost.cloudstream3.syncproviders
import androidx.annotation.WorkerThread
interface InAppAuthAPI : AuthAPI {
data class LoginData(
val username: String? = null,
val password: String? = null,
val server: String? = null,
val email: String? = null,
)
// this is for displaying the UI
val requiresPassword: Boolean
val requiresUsername: Boolean
val requiresServer: Boolean
val requiresEmail: Boolean
// if this is false we can assume that getLatestLoginData returns null and wont be called
// this is used in case for some reason it is not preferred to store any login data besides the "token" or encrypted data
val storesPasswordInPlainText: Boolean
// return true if logged in successfully
suspend fun login(data: LoginData): Boolean
// used to fill the UI if you want to edit any data about your login info
fun getLatestLoginData(): LoginData?
}
abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), InAppAuthAPI {
override val requiresPassword = false
override val requiresUsername = false
override val requiresEmail = false
override val requiresServer = false
override val storesPasswordInPlainText = true
override val requiresLogin = true
// runs on startup
@WorkerThread
open suspend fun initialize() {
}
override fun logOut() {
throw NotImplementedError()
}
override val idPrefix: String
get() = throw NotImplementedError()
override val name: String
get() = throw NotImplementedError()
override val icon: Int? = null
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
throw NotImplementedError()
}
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
throw NotImplementedError()
}
override fun loginInfo(): AuthAPI.LoginInfo? {
throw NotImplementedError()
}
}

View file

@ -0,0 +1,27 @@
package com.lagradost.cloudstream3.syncproviders
import androidx.fragment.app.FragmentActivity
interface OAuth2API : AuthAPI {
val key: String
val redirectUrl: String
val supportDeviceAuth: Boolean
suspend fun handleRedirect(url: String) : Boolean
fun authenticate(activity: FragmentActivity?)
suspend fun getDevicePin() : PinAuthData? {
return null
}
suspend fun handleDeviceAuth(pinAuthData: PinAuthData) : Boolean {
return false
}
data class PinAuthData(
val deviceCode: String,
val userCode: String,
val verificationUrl: String,
val expiresIn: Int,
val interval: Int,
)
}

View file

@ -1,37 +0,0 @@
package com.lagradost.cloudstream3.syncproviders
import androidx.annotation.WorkerThread
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.subtitles.SubtitleResource
/**
* Stateless subtitle class for external subtitles.
*
* All non-null `AuthToken` will be non-expired when each function is called.
*/
abstract class SubtitleAPI : AuthAPI() {
@WorkerThread
@Throws
open suspend fun search(auth: AuthData?, query: SubtitleSearch): List<SubtitleEntity>? =
throw NotImplementedError()
@WorkerThread
@Throws
open suspend fun load(auth: AuthData?, subtitle: SubtitleEntity): String? =
throw NotImplementedError()
@WorkerThread
@Throws
open suspend fun SubtitleResource.getResources(auth: AuthData?, subtitle: SubtitleEntity) {
this.addUrl(load(auth, subtitle))
}
@WorkerThread
@Throws
suspend fun resource(auth: AuthData?, subtitle: SubtitleEntity): SubtitleResource {
return SubtitleResource().apply {
this.getResources(auth, subtitle)
}
}
}

View file

@ -1,95 +0,0 @@
package com.lagradost.cloudstream3.syncproviders
import androidx.annotation.WorkerThread
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
/** Stateless safe abstraction of SubtitleAPI */
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
companion object {
data class SavedSearchResponse(
val unixTime: Long,
val response: List<SubtitleEntity>,
val query: SubtitleSearch,
val idPrefix: String,
)
data class SavedResourceResponse(
val unixTime: Long,
val response: SubtitleResource,
val query: SubtitleEntity
)
// maybe make this a generic struct? right now there is a lot of boilerplate
private val searchCache = atomicListOf<SavedSearchResponse>()
private var searchCacheIndex: Int = 0
private val resourceCache = atomicListOf<SavedResourceResponse>()
private var resourceCacheIndex: Int = 0
const val CACHE_SIZE = 20
}
@WorkerThread
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
val cached = resourceCache.withLock {
var found: SubtitleResource? = null
for (item in resourceCache) {
// 20 min save
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
found = item.response
break
}
}
found
}
if (cached != null) return@runCatching cached
val returnValue = api.resource(freshAuth(), data)
resourceCache.withLock {
val add = SavedResourceResponse(unixTime, returnValue, data)
if (resourceCache.size > CACHE_SIZE) {
resourceCache[resourceCacheIndex] = add // rolling cache
resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE
} else {
resourceCache.add(add)
}
}
returnValue
}
@WorkerThread
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
return runCatching {
val cached = searchCache.withLock {
var found: List<SubtitleEntity>? = null
for (item in searchCache) {
// 120 min save
if (item.idPrefix == idPrefix && item.query == query && (unixTime - item.unixTime) < 60 * 120) {
found = item.response
break
}
}
found
}
if (cached != null) return@runCatching cached
val returnValue = api.search(freshAuth(), query) ?: emptyList()
// only cache valid return values
if (returnValue.isNotEmpty()) {
val add = SavedSearchResponse(unixTime, returnValue, query, idPrefix)
searchCache.withLock {
if (searchCache.size > CACHE_SIZE) {
searchCache[searchCacheIndex] = add // rolling cache
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
} else {
searchCache.add(add)
}
}
}
returnValue
}
}
}

View file

@ -1,194 +1,170 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
import androidx.annotation.WorkerThread import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.SearchQuality import me.xdrop.fuzzywuzzy.FuzzySearch
import com.lagradost.cloudstream3.SearchResponse import java.util.Date
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType interface SyncAPI : OAuth2API {
import com.lagradost.cloudstream3.ui.SyncWatchType /**
import com.lagradost.cloudstream3.ui.library.ListSorting * Set this to true if the user updates something on the list like watch status or score
import com.lagradost.cloudstream3.utils.Levenshtein **/
import com.lagradost.cloudstream3.utils.UiText var requireLibraryRefresh: Boolean
import java.util.Date val mainUrl: String
/** /**
* Stateless synchronization class, used for syncing status about a specific movie/show. * Allows certain providers to open pages from
* * library links.
* All non-null `AuthToken` will be non-expired when each function is called. **/
*/ val syncIdName: SyncIdName
abstract class SyncAPI : AuthAPI() {
/** /**
* Set this to true if the user updates something on the list like watch status or score -1 -> None
**/ 0 -> Watching
open var requireLibraryRefresh: Boolean = true 1 -> Completed
open val mainUrl: String = "NONE" 2 -> OnHold
3 -> Dropped
/** Currently unused, but will be used to correctly render the UI. 4 -> PlanToWatch
* This should specify what sync watch types can be used with this service. */ 5 -> ReWatching
open val supportedWatchTypes: Set<SyncWatchType> = SyncWatchType.entries.toSet() */
/** suspend fun score(id: String, status: AbstractSyncStatus): Boolean
* Allows certain providers to open pages from
* library links. suspend fun getStatus(id: String): AbstractSyncStatus?
**/
open val syncIdName: SyncIdName? = null suspend fun getResult(id: String): SyncResult?
/** Modify the current status of an item */ suspend fun search(name: String): List<SyncSearchResult>?
@Throws
@WorkerThread suspend fun getPersonalLibrary(): LibraryMetadata?
open suspend fun updateStatus(
auth: AuthData?, fun getIdFromUrl(url: String): String
id: String,
newStatus: AbstractSyncStatus data class SyncSearchResult(
): Boolean = throw NotImplementedError() override val name: String,
override val apiName: String,
/** Get the current status of an item */ var syncId: String,
@Throws override val url: String,
@WorkerThread override var posterUrl: String?,
open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? = override var type: TvType? = null,
throw NotImplementedError() override var quality: SearchQuality? = null,
override var posterHeaders: Map<String, String>? = null,
/** Get metadata about an item */ override var id: Int? = null,
@Throws ) : SearchResponse
@WorkerThread
open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError() abstract class AbstractSyncStatus {
abstract var status: SyncWatchType
/** Search this service for any results for a given query */
@Throws /** 1-10 */
@WorkerThread abstract var score: Int?
open suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? = abstract var watchedEpisodes: Int?
throw NotImplementedError() abstract var isFavorite: Boolean?
abstract var maxEpisodes: Int?
/** Get the current library/bookmarks of this service */ }
@Throws
@WorkerThread
open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError() data class SyncStatus(
override var status: SyncWatchType,
/** Helper function, may be used in the future */ /** 1-10 */
@Throws override var score: Int?,
open fun urlToId(url: String): String? = null override var watchedEpisodes: Int?,
override var isFavorite: Boolean? = null,
data class SyncSearchResult( override var maxEpisodes: Int? = null,
override val name: String, ) : AbstractSyncStatus()
override val apiName: String,
var syncId: String, data class SyncResult(
override val url: String, /**Used to verify*/
override var posterUrl: String?, var id: String,
override var type: TvType? = null,
override var quality: SearchQuality? = null, var totalEpisodes: Int? = null,
override var posterHeaders: Map<String, String>? = null,
override var id: Int? = null, var title: String? = null,
override var score: Score? = null, /**1-1000*/
) : SearchResponse var publicScore: Int? = null,
/**In minutes*/
abstract class AbstractSyncStatus { var duration: Int? = null,
abstract var status: SyncWatchType var synopsis: String? = null,
abstract var score: Score? var airStatus: ShowStatus? = null,
abstract var watchedEpisodes: Int? var nextAiring: NextAiring? = null,
abstract var isFavorite: Boolean? var studio: List<String>? = null,
abstract var maxEpisodes: Int? var genres: List<String>? = null,
} var synonyms: List<String>? = null,
var trailers: List<String>? = null,
data class SyncStatus( var isAdult: Boolean? = null,
override var status: SyncWatchType, var posterUrl: String? = null,
override var score: Score?, var backgroundPosterUrl: String? = null,
override var watchedEpisodes: Int?,
override var isFavorite: Boolean? = null, /** In unixtime */
override var maxEpisodes: Int? = null, var startDate: Long? = null,
) : AbstractSyncStatus() /** In unixtime */
var endDate: Long? = null,
data class SyncResult( var recommendations: List<SyncSearchResult>? = null,
/**Used to verify*/ var nextSeason: SyncSearchResult? = null,
var id: String, var prevSeason: SyncSearchResult? = null,
var actors: List<ActorData>? = null,
var totalEpisodes: Int? = null, )
var title: String? = null,
var publicScore: Score? = null, data class Page(
/**In minutes*/ val title: UiText, var items: List<LibraryItem>
var duration: Int? = null, ) {
var synopsis: String? = null, fun sort(method: ListSorting?, query: String? = null) {
var airStatus: ShowStatus? = null, items = when (method) {
var nextAiring: NextAiring? = null, ListSorting.Query ->
var studio: List<String>? = null, if (query != null) {
var genres: List<String>? = null, items.sortedBy {
var synonyms: List<String>? = null, -FuzzySearch.partialRatio(
var trailers: List<String>? = null, query.lowercase(), it.name.lowercase()
var isAdult: Boolean? = null, )
var posterUrl: String? = null, }
var backgroundPosterUrl: String? = null, } else items
ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating ?: 0) }
/** In unixtime */ ListSorting.RatingLow -> items.sortedBy { (it.personalRating ?: 0) }
var startDate: Long? = null, ListSorting.AlphabeticalA -> items.sortedBy { it.name }
/** In unixtime */ ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
var endDate: Long? = null, ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
var recommendations: List<SyncSearchResult>? = null, ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
var nextSeason: SyncSearchResult? = null, ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
var prevSeason: SyncSearchResult? = null, ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
var actors: List<ActorData>? = null, else -> items
) }
}
data class Page( }
val title: UiText, var items: List<LibraryItem>
) { data class LibraryMetadata(
fun sort(method: ListSorting?, query: String? = null) { val allLibraryLists: List<LibraryList>,
items = when (method) { val supportedListSorting: Set<ListSorting>
ListSorting.Query -> )
if (query != null) {
items.sortedBy { data class LibraryList(
-Levenshtein.partialRatio( val name: UiText,
query.lowercase(), it.name.lowercase() val items: List<LibraryItem>
) )
}
} else items data class LibraryItem(
override val name: String,
ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) } override val url: String,
ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) } /**
ListSorting.AlphabeticalA -> items.sortedBy { it.name } * Unique unchanging string used for data storage.
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() * This should be the actual id when you change scores and status
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } * since score changes from library might get added in the future.
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } **/
ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } val syncId: String,
ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } val episodesCompleted: Int?,
else -> items val episodesTotal: Int?,
} /** Out of 100 */
} val personalRating: Int?,
} val lastUpdatedUnixTime: Long?,
override val apiName: String,
data class LibraryMetadata( override var type: TvType?,
val allLibraryLists: List<LibraryList>, override var posterUrl: String?,
val supportedListSorting: Set<ListSorting> override var posterHeaders: Map<String, String>?,
) override var quality: SearchQuality?,
val releaseDate: Date?,
data class LibraryList( override var id: Int? = null,
val name: UiText, val plot : String? = null,
val items: List<LibraryItem> val rating: Int? = null,
) val tags: List<String>? = null
) : SearchResponse
data class LibraryItem( }
override val name: String,
override val url: String,
/**
* Unique unchanging string used for data storage.
* This should be the actual id when you change scores and status
* since score changes from library might get added in the future.
**/
val syncId: String,
val episodesCompleted: Int?,
val episodesTotal: Int?,
val personalRating: Score?,
val lastUpdatedUnixTime: Long?,
override val apiName: String,
override var type: TvType?,
override var posterUrl: String?,
override var posterHeaders: Map<String, String>?,
override var quality: SearchQuality?,
val releaseDate: Date?,
override var id: Int? = null,
val plot: String? = null,
override var score: Score? = null,
val tags: List<String>? = null
) : SearchResponse
}

View file

@ -1,30 +1,48 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
/** Stateless safe abstraction of SyncAPI */ import com.lagradost.cloudstream3.ErrorLoadingException
class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { import com.lagradost.cloudstream3.mvvm.Resource
val syncIdName = api.syncIdName import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
var requireLibraryRefresh: Boolean import com.lagradost.cloudstream3.mvvm.safeApiCall
get() = api.requireLibraryRefresh
set(value) { class SyncRepo(private val repo: SyncAPI) {
api.requireLibraryRefresh = value val idPrefix = repo.idPrefix
} val name = repo.name
val icon = repo.icon
suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result<Boolean> = val mainUrl = repo.mainUrl
runCatching { val requiresLogin = repo.requiresLogin
val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus) val syncIdName = repo.syncIdName
requireLibraryRefresh = true var requireLibraryRefresh: Boolean
status get() = repo.requireLibraryRefresh
} set(value) {
repo.requireLibraryRefresh = value
suspend fun status(id: String): Result<SyncAPI.AbstractSyncStatus?> = runCatching { }
api.status(freshAuth(), id)
} suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource<Boolean> {
return safeApiCall { repo.score(id, status) }
suspend fun load(id: String): Result<SyncAPI.SyncResult?> = runCatching { }
api.load(freshAuth(), id)
} suspend fun getStatus(id: String): Resource<SyncAPI.AbstractSyncStatus> {
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
suspend fun library(): Result<SyncAPI.LibraryMetadata?> = runCatching { }
api.library(freshAuth())
} suspend fun getResult(id: String): Resource<SyncAPI.SyncResult> {
} return safeApiCall { repo.getResult(id) ?: throw ErrorLoadingException("No data") }
}
suspend fun search(query: String): Resource<List<SyncAPI.SyncSearchResult>> {
return safeApiCall { repo.search(query) ?: throw ErrorLoadingException() }
}
suspend fun getPersonalLibrary(): Resource<SyncAPI.LibraryMetadata> {
return safeApiCall { repo.getPersonalLibrary() ?: throw ErrorLoadingException() }
}
fun hasAccount(): Boolean {
return normalSafeApiCall { repo.loginInfo() != null } ?: false
}
fun getIdFromUrl(url: String): String? = normalSafeApiCall {
repo.getIdFromUrl(url)
}
}

View file

@ -1,205 +1,108 @@
package com.lagradost.cloudstream3.syncproviders.providers package com.lagradost.cloudstream3.syncproviders.providers
import com.lagradost.cloudstream3.AllLanguagesName
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.utils.SubtitleHelper
class Addic7ed : SubtitleAPI() { class Addic7ed : AbstractSubApi {
override val name = "Addic7ed" override val name = "Addic7ed"
override val idPrefix = "addic7ed" override val idPrefix = "addic7ed"
override val requiresLogin = false override val requiresLogin = false
override val icon: Nothing? = null
override val createAccountUrl: Nothing? = null
override fun loginInfo(): Nothing? = null
override fun logOut() {}
companion object { companion object {
const val HOST = "https://www.addic7ed.com" const val HOST = "https://www.addic7ed.com"
const val TAG = "ADDIC7ED" const val TAG = "ADDIC7ED"
} }
private fun String.fixUrl(): String { private fun fixUrl(url: String): String {
val url = this
return if (url.startsWith("/")) HOST + url return if (url.startsWith("/")) HOST + url
else if (!url.startsWith("http")) "$HOST/$url" else if (!url.startsWith("http")) "$HOST/$url"
else url else url
} }
override suspend fun search( override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity> {
auth: AuthData?, val lang = query.lang
query: SubtitleSearch val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
): List<SubtitleEntity>? { val queryText = query.query.trim()
val langTagIETF = query.lang ?: AllLanguagesName
val langNumAddic7ed =
langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0
val langName =
langTagIETF2Addic7ed[langTagIETF]?.second ?:
fromTagToEnglishLanguageName(langTagIETF) ?:
"Completed" // this bypasses language filtering
val title = query.query.trim()
val epNum = query.epNumber ?: 0 val epNum = query.epNumber ?: 0
val seasonNum = query.seasonNumber ?: 0 val seasonNum = query.seasonNumber ?: 0
val yearNum = query.year ?: 0 val yearNum = query.year ?: 0
val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title
var downloadPage = ""
fun newSubtitleEntity ( fun cleanResources(
displayName: String?, results: MutableList<AbstractSubtitleEntities.SubtitleEntity>,
link: String?, name: String,
link: String,
headers: Map<String, String>,
isHearingImpaired: Boolean isHearingImpaired: Boolean
): SubtitleEntity? { ) {
if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null results.add(
return SubtitleEntity( AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix, idPrefix = idPrefix,
name = displayName, name = name,
lang = langTagIETF, lang = queryLang.toString(),
data = link, data = link,
source = this.name, source = this.name,
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
epNumber = epNum, epNumber = epNum,
seasonNumber = seasonNum, seasonNumber = seasonNum,
year = yearNum, year = yearNum,
headers = mapOf("referer" to "$HOST/"), headers = headers,
isHearingImpaired = isHearingImpaired isHearingImpaired = isHearingImpaired
)
) )
} }
val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search") val title = queryText.substringBefore("(").trim()
val hostDocument = response.document val url = "$HOST/search.php?search=${title}&Submit=Search"
val hostDocument = app.get(url).document
// 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name var searchResult = ""
if (response.url.contains("/movie/") || response.url.contains("/serie/")) if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
downloadPage = response.url else if (!hostDocument.select("table.tabel")
.isNullOrEmpty()
// 2nd case: found tv series ep list. Redirected to $HOST/show/1234 ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString()
else if (response.url.contains("/show/")) { else {
val showId = response.url.substringAfterLast("/") val show =
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
?.substringBefore(",")
val doc = app.get( val doc = app.get(
"$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
referer = "$HOST/" referer = "$HOST/"
).document ).document
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
// get direct subtitles links from list if (node.selectFirst("td")?.text()
return doc.select("#season tbody tr").mapNotNull { node -> ?.toIntOrNull() == seasonNum && node.select("td:eq(1)")
if (node.select("td:eq(1)").text().toIntOrNull() == epNum) .text()
newSubtitleEntity( .toIntOrNull() == epNum
displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(), ) searchResult = fixUrl(node.select("a").attr("href"))
link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(),
isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty()
)
else null
} }
// 3rd case: found several or no results. Still in $HOST/search.php?search=title
} else {// (response.url.contains("/search.php"))
downloadPage = hostDocument.select("table.tabel a").selectFirst({
// tv series
if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]"
// movie + year
else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)"
// movie
else "a[href~=movie\\/]"
}())?.attr("href")?.fixUrl() ?: return null
} }
val results = mutableListOf<AbstractSubtitleEntities.SubtitleEntity>()
val document = app.get(
url = fixUrl(searchResult),
).document
// filter download page by language. Do not work for movies :/ document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node ->
if (downloadPage.contains("/serie/")) val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${
downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed" node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")
val doc = app.get(url = downloadPage).document }" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}"
val link = fixUrl(node.select("a.buttonDownload").attr("href"))
// get subtitles links from download page
return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node ->
val displayName =
doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" +
node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration")
val link =
node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl()
val isHearingImpaired = val isHearingImpaired =
node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
newSubtitleEntity(displayName, link, isHearingImpaired)
} }
return results
} }
override suspend fun load( override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String {
auth: AuthData?, return data.data
subtitle: SubtitleEntity
): String? {
return subtitle.data
} }
// Missing (?_?)
// Pair("2", ""),
// Pair("3", ""),
// Pair("33", ""),
// Pair("34", ""),
// Do not modify unless Addic7ed changes them!
// as they are the exact values from their website
private val langTagIETF2Addic7ed = mapOf(
"ar" to Pair("38", "Arabic"),
"az" to Pair("48", "Azerbaijani"),
"bg" to Pair("35", "Bulgarian"),
"bn" to Pair("47", "Bengali"),
"bs" to Pair("44", "Bosnian"),
"ca" to Pair("12", "Català"),
"cs" to Pair("14", "Czech"),
"cy" to Pair("65", "Welsh"),
"da" to Pair("30", "Danish"),
"de" to Pair("11", "German"),
"el" to Pair("27", "Greek"),
"en" to Pair("1", "English"),
"es-419" to Pair("6", "Spanish (Latin America)"),
"es-ar" to Pair("69", "Spanish (Argentina)"),
"es-es" to Pair("5", "Spanish (Spain)"),
"es" to Pair("4", "Spanish"),
"et" to Pair("54", "Estonian"),
"eu" to Pair("13", "Euskera"),
"fa" to Pair("43", "Persian"),
"fi" to Pair("28", "Finnish"),
"fr-ca" to Pair("53", "French (Canadian)"),
"fr" to Pair("8", "French"),
"gl" to Pair("15", "Galego"),
"he" to Pair("23", "Hebrew"),
"hi" to Pair("55", "Hindi"),
"hr" to Pair("31", "Croatian"),
"hu" to Pair("20", "Hungarian"),
"hy" to Pair("50", "Armenian"),
"id" to Pair("37", "Indonesian"),
"is" to Pair("56", "Icelandic"),
"it" to Pair("7", "Italian"),
"ja" to Pair("32", "Japanese"),
"kn" to Pair("66", "Kannada"),
"ko" to Pair("42", "Korean"),
"lt" to Pair("58", "Lithuanian"),
"lv" to Pair("57", "Latvian"),
"mk" to Pair("49", "Macedonian"),
"ml" to Pair("67", "Malayalam"),
"mr" to Pair("62", "Marathi"),
"ms" to Pair("40", "Malay"),
"nl" to Pair("17", "Dutch"),
"no" to Pair("29", "Norwegian"),
"pl" to Pair("21", "Polish"),
"pt-br" to Pair("10", "Portuguese (Brazilian)"),
"pt" to Pair("9", "Portuguese"),
"ro" to Pair("26", "Romanian"),
"ru" to Pair("19", "Russian"),
"si" to Pair("60", "Sinhala"),
"sk" to Pair("25", "Slovak"),
"sl" to Pair("22", "Slovenian"),
"sq" to Pair("52", "Albanian"),
"sr-latn" to Pair("36", "Serbian (Latin)"),
"sr" to Pair("39", "Serbian (Cyrillic)"),
"sv" to Pair("18", "Swedish"),
"ta" to Pair("59", "Tamil"),
"te" to Pair("63", "Telugu"),
"th" to Pair("46", "Thai"),
"tl" to Pair("68", "Tagalog"),
"tlh" to Pair("61", "Klingon"),
"tr" to Pair("16", "Turkish"),
"uk" to Pair("51", "Ukrainian"),
"vi" to Pair("45", "Vietnamese"),
"yue" to Pair("64", "Cantonese"),
"zh-hans" to Pair("41", "Chinese (Simplified)"),
"zh-hant" to Pair("24", "Chinese (Traditional)"),
)
} }

View file

@ -0,0 +1,35 @@
package com.lagradost.cloudstream3.syncproviders.providers
import androidx.fragment.app.FragmentActivity
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.OAuth2API
//TODO dropbox sync
class Dropbox : OAuth2API {
override val idPrefix = "dropbox"
override var name = "Dropbox"
override val key = "zlqsamadlwydvb2"
override val redirectUrl = "dropboxlogin"
override val requiresLogin = true
override val supportDeviceAuth = false
override val createAccountUrl: String? = null
override val icon: Int
get() = TODO("Not yet implemented")
override fun authenticate(activity: FragmentActivity?) {
TODO("Not yet implemented")
}
override suspend fun handleRedirect(url: String): Boolean {
TODO("Not yet implemented")
}
override fun logOut() {
TODO("Not yet implemented")
}
override fun loginInfo(): AuthAPI.LoginInfo? {
TODO("Not yet implemented")
}
}

View file

@ -1,658 +1,8 @@
package com.lagradost.cloudstream3.syncproviders.providers package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.txt
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.util.Date
import java.util.Locale
const val KITSU_MAX_SEARCH_LIMIT = 20
class KitsuApi: SyncAPI() {
override var name = "Kitsu"
override val idPrefix = "kitsu"
private val apiUrl = "https://kitsu.io/api/edge"
private val fallbackApiUrl = "https://kitsu.app/api/edge"
private val oauthUrl = "https://kitsu.io/api/oauth"
private val fallbackOauthUrl = "https://kitsu.app/api/oauth"
override val hasInApp = true
override val mainUrl = "https://kitsu.app"
override val icon = R.drawable.kitsu_icon
override val syncIdName = SyncIdName.Kitsu
override val createAccountUrl = mainUrl
override val supportedWatchTypes = setOf(
SyncWatchType.WATCHING,
SyncWatchType.COMPLETED,
SyncWatchType.PLANTOWATCH,
SyncWatchType.DROPPED,
SyncWatchType.ONHOLD,
SyncWatchType.NONE
)
override val inAppLoginRequirement = AuthLoginRequirement(
password = true,
email = true
)
private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
try {
val response = chain.proceed(request);
if (response.isSuccessful) return response
response.close()
} catch (_: Exception) {
}
val fallbackRequest: Request = request.newBuilder()
.url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl))
.build()
return chain.proceed(fallbackRequest)
}
}
private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl)
private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl)
override suspend fun login(form: AuthLoginResponse): AuthToken? {
val username = form.email ?: return null
val password = form.password ?: return null
val grantType = "password"
val token = app.post(
"$oauthUrl/token",
data = mapOf(
"grant_type" to grantType,
"username" to username,
"password" to password
),
interceptor = oauthFallbackInterceptor
).parsed<ResponseToken>()
return AuthToken(
accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(),
refreshToken = token.refreshToken,
accessToken = token.accessToken,
)
}
override suspend fun refreshToken(token: AuthToken): AuthToken {
val res = app.post(
"$oauthUrl/token",
data = mapOf(
"grant_type" to "refresh_token",
"refresh_token" to token.refreshToken!!
),
interceptor = oauthFallbackInterceptor
).parsed<ResponseToken>()
return AuthToken(
accessToken = res.accessToken,
refreshToken = res.refreshToken,
accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong()
)
}
override suspend fun user(token: AuthToken?): AuthUser? {
val user = app.get(
"$apiUrl/users?filter[self]=true",
headers = mapOf(
"Authorization" to "Bearer ${token?.accessToken ?: return null}"
), cacheTime = 0,
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
if (user.data.isEmpty()) {
return null
}
return AuthUser(
id = user.data[0].id.toInt(),
name = user.data[0].attributes.name,
profilePicture = user.data[0].attributes.avatar?.original
)
}
override suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? {
val auth = auth?.token?.accessToken ?: return null
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount")
val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth",
), cacheTime = 0,
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
return res.data.map {
val attributes = it.attributes
val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title"
SyncSearchResult(
title,
this.name,
it.id,
"$mainUrl/anime/${it.id}/",
attributes.posterImage?.large ?: attributes.posterImage?.medium
)
}
}
override suspend fun load(auth : AuthData?, id: String): SyncResult? {
val auth = auth?.token?.accessToken ?: return null
if (id.toIntOrNull() == null) {
return null
}
@Serializable
data class KitsuResponse(
@JsonProperty("data") @SerialName("data") val data: KitsuNode,
)
val url =
"$apiUrl/anime/$id"
val anime = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.attributes
return SyncResult(
id = id,
totalEpisodes = anime.episodeCount,
title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(),
publicScore = Score.from(anime.ratingTwenty, 20),
duration = anime.episodeLength,
synopsis = anime.synopsis,
airStatus = when (anime.status) {
"finished" -> ShowStatus.Completed
"current" -> ShowStatus.Ongoing
else -> null
},
nextAiring = null,
studio = null,
genres = null,
trailers = null,
startDate = LocalDate.parse(anime.startDate).toEpochDay(),
endDate = LocalDate.parse(anime.endDate).toEpochDay(),
recommendations = null,
nextSeason =null,
prevSeason = null,
actors = null,
)
}
override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? {
val accessToken = auth?.token?.accessToken ?: return null
val userId = auth.user.id
val selectedFields = arrayOf("status","ratingTwenty", "progress")
val url =
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}"
val anime = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $accessToken"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.firstOrNull()?.attributes
if (anime == null) {
return SyncStatus(
score = null,
status = SyncWatchType.NONE,
isFavorite = null,
watchedEpisodes = null
)
}
return SyncStatus(
score = Score.from(anime.ratingTwenty, 20),
status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)),
isFavorite = null,
watchedEpisodes = anime.progress,
)
}
suspend fun getAnimeIdByTitle(title: String): String? {
val animeSelectedFields = arrayOf("titles","canonicalTitle")
val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
val res = app.get(url, interceptor = apiFallbackInterceptor).parsed<KitsuResponse>()
return res.data.firstOrNull()?.id
}
override fun urlToId(url: String): String? =
Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first()
override suspend fun updateStatus(
auth : AuthData?,
id: String,
newStatus: AbstractSyncStatus
): Boolean {
return setScoreRequest(
auth ?: return false,
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(newStatus.status),
newStatus.score?.toInt(20),
newStatus.watchedEpisodes
)
}
private suspend fun setScoreRequest(
auth: AuthData,
id: Int,
status: KitsuStatusType? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val libraryEntryId = getAnimeLibraryEntryId(auth, id)
// Exists entry for anime in library
if (libraryEntryId != null) {
// Delete anime from library
if (status == null || status == KitsuStatusType.None) {
val res = app.delete(
"$apiUrl/library-entries/$libraryEntryId",
headers = mapOf(
"Authorization" to "Bearer ${auth.token.accessToken}"
),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
return setScoreRequest(
auth,
libraryEntryId,
kitsuStatusAsString[maxOf(0, status.value)],
score,
numWatchedEpisodes
)
}
val data = mapOf(
"data" to mapOf(
"type" to "libraryEntries",
"attributes" to mapOf(
"ratingTwenty" to score,
"progress" to numWatchedEpisodes,
"status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)],
),
"relationships" to mapOf(
"anime" to mapOf(
"data" to mapOf(
"type" to "anime",
"id" to id.toString()
)
),
"user" to mapOf(
"data" to mapOf(
"type" to "users",
"id" to auth.user.id
)
)
)
)
)
val res = app.post(
"$apiUrl/library-entries",
headers = mapOf(
"content-type" to "application/vnd.api+json",
"Authorization" to "Bearer ${auth.token.accessToken}"
),
requestBody = data.toJson().toRequestBody(),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
@Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest(
auth : AuthData,
id: Int,
status: String? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val data = mapOf(
"data" to mapOf(
"type" to "libraryEntries",
"id" to id.toString(),
"attributes" to mapOf(
"ratingTwenty" to score,
"progress" to numWatchedEpisodes,
"status" to status
)
)
)
val res = app.patch(
"$apiUrl/library-entries/$id",
headers = mapOf(
"content-type" to "application/vnd.api+json",
"Authorization" to "Bearer ${auth.token.accessToken}"
),
requestBody = data.toJson().toRequestBody(),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? {
val userId = auth.user.id
val res = app.get(
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id",
headers = mapOf(
"Authorization" to "Bearer ${auth.token.accessToken}"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.firstOrNull() ?: return null
return res.id.toInt()
}
override suspend fun library(auth : AuthData?): LibraryMetadata? {
val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy {
convertToStatus(it.attributes.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
} ?: emptyMap()
// To fill empty lists when Kitsu does not return them
val baseMap =
KitsuStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<LibraryItem>()
}
return LibraryMetadata(
(baseMap + list).map { LibraryList(txt(it.key), it.value) },
setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
)
}
private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array<KitsuNode>? {
return if (requireLibraryRefresh) {
val list = getKitsuAnimeList(auth.token, auth.user.id)
setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list)
list
} else {
getKey<Array<KitsuNode>>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array<KitsuNode>
}
}
private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array<KitsuNode> {
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount")
val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status")
val limit = 500
var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}"
val fullList = mutableListOf<KitsuNode>()
while (true) {
val data: KitsuResponse = getKitsuAnimeListSlice(token, url)
data.data.forEachIndexed { index, value ->
value.anime = data.included?.get(index)
}
fullList.addAll(data.data)
url = data.links?.next ?: break
}
return fullList.toTypedArray()
}
private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse {
return app.get(
url, headers = mapOf(
"Authorization" to "Bearer ${token.accessToken}",
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
}
@Serializable
data class ResponseToken(
@JsonProperty("token_type") @SerialName("token_type") val tokenType: String,
@JsonProperty("expires_in") @SerialName("expires_in") val expiresIn: Int,
@JsonProperty("access_token") @SerialName("access_token") val accessToken: String,
@JsonProperty("refresh_token") @SerialName("refresh_token") val refreshToken: String,
)
@Serializable
data class KitsuNode(
@JsonProperty("id") @SerialName("id") val id: String,
@JsonProperty("attributes") @SerialName("attributes") val attributes: KitsuNodeAttributes,
/* User list anime node */
@JsonProperty("relationships") @SerialName("relationships") val relationships: KitsuRelationships?,
@JsonProperty("anime") @SerialName("anime") var anime: KitsuAnimeData?,
) {
fun toLibraryItem(): LibraryItem {
val animeItem = this.anime
val numEpisodes = animeItem?.attributes?.episodeCount
val startDate = animeItem?.attributes?.startDate
val posterImage = animeItem?.attributes?.posterImage
val canonicalTitle = animeItem?.attributes?.canonicalTitle
val titles = animeItem?.attributes?.titles
val animeId = animeItem?.id
val synopsis: String? = animeItem?.attributes?.synopsis
return LibraryItem(
canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(),
"https://kitsu.app/anime/${animeId}/",
this.id,
this.attributes.progress,
numEpisodes,
Score.from(this.attributes.ratingTwenty, 20),
parseDateLong(this.attributes.updatedAt),
"Kitsu",
TvType.Anime,
posterImage?.large ?: posterImage?.medium,
null,
null,
plot = synopsis,
releaseDate = if (startDate == null) null else try {
Date.from(LocalDate.parse(startDate).atStartOfDay()
.atZone(ZoneId.systemDefault())
.toInstant())
} catch (_: RuntimeException) {
null
}
)
}
}
@Serializable
data class KitsuAnimeAttributes(
@JsonProperty("titles") @SerialName("titles") val titles: KitsuTitles?,
@JsonProperty("canonicalTitle") @SerialName("canonicalTitle") val canonicalTitle: String?,
@JsonProperty("posterImage") @SerialName("posterImage") val posterImage: KitsuPosterImage?,
@JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?,
@JsonProperty("startDate") @SerialName("startDate") val startDate: String?,
@JsonProperty("endDate") @SerialName("endDate") val endDate: String?,
@JsonProperty("episodeCount") @SerialName("episodeCount") val episodeCount: Int?,
@JsonProperty("episodeLength") @SerialName("episodeLength") val episodeLength: Int?,
)
@Serializable
data class KitsuAnimeData(
@JsonProperty("id") @SerialName("id") val id: String,
@JsonProperty("attributes") @SerialName("attributes") val attributes: KitsuAnimeAttributes,
)
@Serializable
data class KitsuNodeAttributes(
/* General attributes */
@JsonProperty("titles") @SerialName("titles") val titles: KitsuTitles?,
@JsonProperty("canonicalTitle") @SerialName("canonicalTitle") val canonicalTitle: String?,
@JsonProperty("posterImage") @SerialName("posterImage") val posterImage: KitsuPosterImage?,
@JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?,
@JsonProperty("startDate") @SerialName("startDate") val startDate: String?,
@JsonProperty("endDate") @SerialName("endDate") val endDate: String?,
@JsonProperty("episodeCount") @SerialName("episodeCount") val episodeCount: Int?,
@JsonProperty("episodeLength") @SerialName("episodeLength") val episodeLength: Int?,
/* User attributes */
@JsonProperty("name") @SerialName("name") val name: String?,
@JsonProperty("location") @SerialName("location") val location: String?,
@JsonProperty("createdAt") @SerialName("createdAt") val createdAt: String?,
@JsonProperty("avatar") @SerialName("avatar") val avatar: KitsuUserAvatar?,
/* User list anime attributes */
@JsonProperty("progress") @SerialName("progress") val progress: Int?,
@JsonProperty("ratingTwenty") @SerialName("ratingTwenty") val ratingTwenty: Int?,
@JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: String?,
@JsonProperty("status") @SerialName("status") val status: String?,
)
@Serializable
data class KitsuRelationships(
@JsonProperty("anime") @SerialName("anime") val anime: KitsuRelationshipsAnime?,
)
@Serializable
data class KitsuRelationshipsAnime(
@JsonProperty("links") @SerialName("links") val links: KitsuLinks?,
)
@Serializable
data class KitsuPosterImage(
@JsonProperty("large") @SerialName("large") val large: String?,
@JsonProperty("medium") @SerialName("medium") val medium: String?,
)
@Serializable
data class KitsuTitles(
@JsonProperty("en_jp") @SerialName("en_jp") val enJp: String?,
@JsonProperty("ja_jp") @SerialName("ja_jp") val jaJp: String?,
)
@Serializable
data class KitsuUserAvatar(
@JsonProperty("original") @SerialName("original") val original: String?,
)
@Serializable
data class KitsuLinks(
/* Pagination */
@JsonProperty("first") @SerialName("first") val first: String?,
@JsonProperty("next") @SerialName("next") val next: String?,
@JsonProperty("last") @SerialName("last") val last: String?,
/* Relationships */
@JsonProperty("related") @SerialName("related") val related: String?,
)
@Serializable
data class KitsuResponse(
@JsonProperty("links") @SerialName("links") val links: KitsuLinks?,
@JsonProperty("data") @SerialName("data") val data: List<KitsuNode>,
/* When requesting related info (User library entry -> anime) */
@JsonProperty("included") @SerialName("included") val included: List<KitsuAnimeData>?,
)
companion object {
const val KITSU_CACHED_LIST: String = "kitsu_cached_list"
private fun parseDateLong(string: String?): Long? {
return try {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(
string ?: return null
)?.time?.div(1000)
} catch (_: Exception) {
null
}
}
private val kitsuStatusAsString =
arrayOf("current", "completed", "on_hold", "dropped", "planned")
private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType {
return when (inp) {
SyncWatchType.NONE -> KitsuStatusType.None
SyncWatchType.WATCHING -> KitsuStatusType.Watching
SyncWatchType.COMPLETED -> KitsuStatusType.Completed
SyncWatchType.ONHOLD -> KitsuStatusType.OnHold
SyncWatchType.DROPPED -> KitsuStatusType.Dropped
SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch
SyncWatchType.REWATCHING -> KitsuStatusType.Watching
}
}
enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) {
Watching(0, R.string.type_watching),
Completed(1, R.string.type_completed),
OnHold(2, R.string.type_on_hold),
Dropped(3, R.string.type_dropped),
PlanToWatch(4, R.string.type_plan_to_watch),
None(-1, R.string.type_none)
}
private fun convertToStatus(string: String): KitsuStatusType {
return when (string) {
"current" -> KitsuStatusType.Watching
"completed" -> KitsuStatusType.Completed
"on_hold" -> KitsuStatusType.OnHold
"dropped" -> KitsuStatusType.Dropped
"planned" -> KitsuStatusType.PlanToWatch
else -> KitsuStatusType.None
}
}
}
}
// modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
@ -670,7 +20,7 @@ object Kitsu {
"https://kitsu.io/api/graphql", "https://kitsu.io/api/graphql",
headers = headers, headers = headers,
data = mapOf("query" to query) data = mapOf("query" to query)
).parsed<KitsuResponse>() ).parsed()
} }
private val cache: MutableMap<Pair<String, String>, Map<Int, KitsuResponse.Node>> = private val cache: MutableMap<Pair<String, String>, Map<Int, KitsuResponse.Node>> =
@ -752,52 +102,44 @@ query {
return map return map
} }
@Serializable
data class KitsuResponse( data class KitsuResponse(
@JsonProperty("data") @SerialName("data") val data: Data? = null, val data: Data? = null
) { ) {
@Serializable
data class Data( data class Data(
@JsonProperty("lookupMapping") @SerialName("lookupMapping") val lookupMapping: LookupMapping? = null, val lookupMapping: LookupMapping? = null
) )
@Serializable
data class LookupMapping( data class LookupMapping(
@JsonProperty("id") @SerialName("id") val id: String? = null, val id: String? = null,
@JsonProperty("episodes") @SerialName("episodes") val episodes: Episodes? = null, val episodes: Episodes? = null
) )
@Serializable
data class Episodes( data class Episodes(
@JsonProperty("nodes") @SerialName("nodes") val nodes: List<Node?>? = null, val nodes: List<Node?>? = null
) )
@Serializable
data class Node( data class Node(
@JsonProperty("number") @SerialName("number") val num: Int? = null, @JsonProperty("number")
@JsonProperty("titles") @SerialName("titles") val titles: Titles? = null, val num: Int? = null,
@JsonProperty("description") @SerialName("description") val description: Description? = null, val titles: Titles? = null,
@JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: Thumbnail? = null, val description: Description? = null,
val thumbnail: Thumbnail? = null
) )
@Serializable
data class Description( data class Description(
@JsonProperty("en") @SerialName("en") val en: String? = null, val en: String? = null
) )
@Serializable
data class Thumbnail( data class Thumbnail(
@JsonProperty("original") @SerialName("original") val original: Original? = null, val original: Original? = null
) )
@Serializable
data class Original( data class Original(
@JsonProperty("url") @SerialName("url") val url: String? = null, val url: String? = null
) )
@Serializable
data class Titles( data class Titles(
@JsonProperty("canonical") @SerialName("canonical") val canonical: String? = null, val canonical: String? = null
) )
} }
} }

View file

@ -1,11 +1,13 @@
package com.lagradost.cloudstream3.syncproviders.providers package com.lagradost.cloudstream3.syncproviders.providers
import androidx.fragment.app.FragmentActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWork
@ -14,19 +16,56 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
import com.lagradost.cloudstream3.utils.txt
class LocalList : SyncAPI() { class LocalList : SyncAPI {
override val name = "Local" override val name = "Local"
override val idPrefix = "local"
override val icon: Int = R.drawable.ic_baseline_storage_24 override val icon: Int = R.drawable.ic_baseline_storage_24
override val requiresLogin = false override val requiresLogin = false
override val createAccountUrl = null override val supportDeviceAuth = false
override val createAccountUrl: Nothing? = null
override val idPrefix = "local"
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
override val syncIdName = SyncIdName.LocalList
override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { override fun loginInfo(): AuthAPI.LoginInfo {
return AuthAPI.LoginInfo(
null,
null,
0
)
}
override fun logOut() {
}
override val key: String = ""
override val redirectUrl = ""
override suspend fun handleRedirect(url: String): Boolean {
return true
}
override fun authenticate(activity: FragmentActivity?) {
}
override val mainUrl = ""
override val syncIdName = SyncIdName.LocalList
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return true
}
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
return null
}
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
return null
}
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
return null
}
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
val watchStatusIds = ioWork { val watchStatusIds = ioWork {
getAllWatchStateIds()?.map { id -> getAllWatchStateIds()?.map { id ->
Pair(id, getResultWatchState(id)) Pair(id, getResultWatchState(id))
@ -63,10 +102,9 @@ class LocalList : SyncAPI() {
val result = if (isTrueTv) { val result = if (isTrueTv) {
baseMap + watchStatusMap + favoritesMap baseMap + watchStatusMap + favoritesMap
} else { } else {
val subscriptionsMap = val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { it.toLibraryItem()
it.toLibraryItem() })
})
baseMap + watchStatusMap + subscriptionsMap + favoritesMap baseMap + watchStatusMap + subscriptionsMap + favoritesMap
} }
@ -74,8 +112,8 @@ class LocalList : SyncAPI() {
result result
} }
return LibraryMetadata( return SyncAPI.LibraryMetadata(
list.map { LibraryList(txt(it.key), it.value) }, list.map { SyncAPI.LibraryList(txt(it.key), it.value) },
setOf( setOf(
ListSorting.AlphabeticalA, ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ, ListSorting.AlphabeticalZ,
@ -89,4 +127,8 @@ class LocalList : SyncAPI() {
) )
) )
} }
override fun getIdFromUrl(url: String): String {
return url
}
} }

View file

@ -2,112 +2,188 @@ package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppUtils.parseJson import okhttp3.Interceptor
import com.lagradost.cloudstream3.utils.AppUtils.toJson import okhttp3.Response
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class OpenSubtitlesApi : SubtitleAPI() { class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
override val name = "OpenSubtitles"
override val idPrefix = "opensubtitles" override val idPrefix = "opensubtitles"
override val name = "OpenSubtitles"
override val icon = R.drawable.open_subtitles_icon override val icon = R.drawable.open_subtitles_icon
override val hasInApp = true override val requiresPassword = true
override val inAppLoginRequirement = AuthLoginRequirement( override val requiresUsername = true
password = true,
username = true,
)
override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up" override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up"
companion object { companion object {
const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile
const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
const val HOST = "https://api.opensubtitles.com/api/v1" const val HOST = "https://api.opensubtitles.com/api/v1"
const val TAG = "OPENSUBS" const val TAG = "OPENSUBS"
const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms
var currentCoolDown: Long = 0L var currentCoolDown: Long = 0L
const val userAgent = "Cloudstream3 v0.2" var currentSession: SubtitleOAuthEntity? = null
val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY) }
private val headerInterceptor = OpenSubtitleInterceptor()
/** Automatically adds required api headers */
private class OpenSubtitleInterceptor : Interceptor {
/** Required user agent! */
private val userAgent = "Cloudstream3 v0.2"
override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(
chain.request().newBuilder()
.removeHeader("user-agent")
.addHeader("user-agent", userAgent)
.addHeader("Api-Key", API_KEY)
.build()
)
}
} }
private fun canDoRequest(): Boolean { private fun canDoRequest(): Boolean {
return unixTimeMS > currentCoolDown return unixTimeMs > currentCoolDown
} }
private fun throwIfCantDoRequest() { private fun throwIfCantDoRequest() {
if (!canDoRequest()) { if (!canDoRequest()) {
throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMS) / 1000L}s") throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s")
} }
} }
private fun throwGotTooManyRequests() { private fun throwGotTooManyRequests() {
currentCoolDown = unixTimeMS + COOLDOWN_DURATION currentCoolDown = unixTimeMs + COOLDOWN_DURATION
throw ErrorLoadingException("Too many requests") throw ErrorLoadingException("Too many requests")
} }
override suspend fun refreshToken(token: AuthToken): AuthToken? { private fun getAuthKey(): SubtitleOAuthEntity? {
return login(parseJson<AuthLoginResponse>(token.payload ?: return null)) return getKey(accountId, OPEN_SUBTITLES_USER_KEY)
} }
override suspend fun user(token: AuthToken?): AuthUser? { private fun setAuthKey(data: SubtitleOAuthEntity?) {
val user = parseJson<AuthLoginResponse>(token?.payload ?: return null) if (data == null) removeKey(accountId, OPEN_SUBTITLES_USER_KEY)
val username = user.username ?: return null currentSession = data
return AuthUser( setKey(accountId, OPEN_SUBTITLES_USER_KEY, data)
id = username.hashCode(),
name = username
)
} }
override suspend fun login(form: AuthLoginResponse): AuthToken? { override fun loginInfo(): AuthAPI.LoginInfo? {
val username = form.username ?: return null getAuthKey()?.let { user ->
val password = form.password ?: return null return AuthAPI.LoginInfo(
profilePicture = null,
name = user.user,
accountIndex = accountIndex
)
}
return null
}
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
val current = getAuthKey() ?: return null
return InAppAuthAPI.LoginData(username = current.user, current.pass)
}
/*
Authorize app to connect to API, using username/password.
Required to run at startup.
Returns OAuth entity with valid access token.
*/
override suspend fun initialize() {
currentSession = getAuthKey() ?: return // just in case the following fails
initLogin(currentSession?.user ?: return, currentSession?.pass ?: return)
}
override fun logOut() {
setAuthKey(null)
removeAccountKeys()
currentSession = getAuthKey()
}
private suspend fun initLogin(username: String, password: String): Boolean {
//Log.i(TAG, "DATA = [$username] [$password]")
val response = app.post( val response = app.post(
url = "$HOST/login", url = "$HOST/login",
headers = mapOf( headers = mapOf(
"Content-Type" to "application/json", "Content-Type" to "application/json",
) + headers, ),
json = mapOf( json = mapOf(
"username" to username, "username" to username,
"password" to password "password" to password
), ),
).parsed<OAuthToken>() interceptor = headerInterceptor
return AuthToken(
accessToken = response.token
?: throw ErrorLoadingException("Invalid password or username"),
/// JWT token is valid 24 hours after successfully authentication of user
accessTokenLifetime = APIHolder.unixTime + 60 * 60 * 24,
payload = form.toJson()
) )
//Log.i(TAG, "Responsecode = ${response.code}")
//Log.i(TAG, "Result => ${response.text}")
if (response.isSuccessful) {
AppUtils.tryParseJson<OAuthToken>(response.text)?.let { token ->
setAuthKey(
SubtitleOAuthEntity(
user = username,
pass = password,
accessToken = token.token ?: run {
return false
})
)
}
return true
}
return false
}
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
val username = data.username ?: throw ErrorLoadingException("Requires Username")
val password = data.password ?: throw ErrorLoadingException("Requires Password")
switchToNewAccount()
try {
if (initLogin(username, password)) {
registerAccount()
return true
}
} catch (e: Exception) {
logError(e)
switchToOldAccount()
}
switchToOldAccount()
return false
}
/**
* Some languages do not use the normal country codes on OpenSubtitles
* */
private val languageExceptions = mapOf<String, String>(
// "pt" to "pt-PT",
// "pt" to "pt-BR"
)
private fun fixLanguage(language: String?): String? {
return languageExceptions[language] ?: language
}
// O(n) but good enough, BiMap did not want to work properly
private fun fixLanguageReverse(language: String?): String? {
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
} }
/** /**
* Fetch subtitles using token authenticated on previous method (see authorize). * Fetch subtitles using token authenticated on previous method (see authorize).
* Returns list of Subtitles which user can select to download (see load). * Returns list of Subtitles which user can select to download (see load).
*/ * */
override suspend fun search( override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
auth : AuthData?,
query: AbstractSubtitleEntities.SubtitleSearch
): List<AbstractSubtitleEntities.SubtitleEntity>? {
throwIfCantDoRequest() throwIfCantDoRequest()
val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" val fixedLang = fixLanguage(query.lang)
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
val queryText = query.query val queryText = query.query
@ -120,17 +196,17 @@ class OpenSubtitlesApi : SubtitleAPI() {
val searchQueryUrl = when (imdbId > 0) { val searchQueryUrl = when (imdbId > 0) {
//Use imdb_id to search if its valid //Use imdb_id to search if its valid
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
} }
val req = app.get( val req = app.get(
url = searchQueryUrl, url = searchQueryUrl,
headers = mapOf( headers = mapOf(
Pair("Content-Type", "application/json") Pair("Content-Type", "application/json")
) + headers, ),
interceptor = headerInterceptor
) )
Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}")
Log.i(TAG, "Search Req => ${req.text}") Log.i(TAG, "Search Req => ${req.text}")
if (!req.isSuccessful) { if (!req.isSuccessful) {
if (req.code == 429) if (req.code == 429)
@ -151,7 +227,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
//Use any valid name/title in hierarchy //Use any valid name/title in hierarchy
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
?: featureDetails?.parentTitle ?: attr.release ?: query.query ?: featureDetails?.parentTitle ?: attr.release ?: query.query
val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" val lang = fixLanguageReverse(attr.language) ?: ""
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
val year = featureDetails?.year ?: query.year val year = featureDetails?.year ?: query.year
@ -165,7 +241,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
AbstractSubtitleEntities.SubtitleEntity( AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix, idPrefix = this.idPrefix,
name = name, name = name,
lang = langTagIETF, lang = lang,
data = resultData, data = resultData,
type = type, type = type,
source = this.name, source = this.name,
@ -181,15 +257,11 @@ class OpenSubtitlesApi : SubtitleAPI() {
return results return results
} }
/** /*
* Process data returned from search. Process data returned from search.
* Returns string url for the subtitle file. Returns string url for the subtitle file.
*/ */
override suspend fun load( override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? {
auth : AuthData?,
subtitle: AbstractSubtitleEntities.SubtitleEntity
): String? {
if (auth == null) return null
throwIfCantDoRequest() throwIfCantDoRequest()
val req = app.post( val req = app.post(
@ -197,14 +269,15 @@ class OpenSubtitlesApi : SubtitleAPI() {
headers = mapOf( headers = mapOf(
Pair( Pair(
"Authorization", "Authorization",
"Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" "Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
), ),
Pair("Content-Type", "application/json"), Pair("Content-Type", "application/json"),
Pair("Accept", "*/*") Pair("Accept", "*/*")
) + headers, ),
data = mapOf( data = mapOf(
Pair("file_id", subtitle.data) Pair("file_id", data.data)
) ),
interceptor = headerInterceptor
) )
Log.i(TAG, "Request result => (${req.code}) ${req.text}") Log.i(TAG, "Request result => (${req.code}) ${req.text}")
//Log.i(TAG, "Request headers => ${req.headers}") //Log.i(TAG, "Request headers => ${req.headers}")
@ -221,64 +294,64 @@ class OpenSubtitlesApi : SubtitleAPI() {
return null return null
} }
@Serializable
data class SubtitleOAuthEntity(
var user: String,
var pass: String,
var accessToken: String,
)
data class OAuthToken( data class OAuthToken(
@JsonProperty("token") @SerialName("token") var token: String? = null, @JsonProperty("token") var token: String? = null,
@JsonProperty("status") @SerialName("status") var status: Int? = null, @JsonProperty("status") var status: Int? = null
) )
@Serializable
data class Results( data class Results(
@JsonProperty("data") @SerialName("data") var data: List<ResultData>? = listOf(), @JsonProperty("data") var data: List<ResultData>? = listOf()
) )
@Serializable
data class ResultData( data class ResultData(
@JsonProperty("id") @SerialName("id") var id: String? = null, @JsonProperty("id") var id: String? = null,
@JsonProperty("type") @SerialName("type") var type: String? = null, @JsonProperty("type") var type: String? = null,
@JsonProperty("attributes") @SerialName("attributes") var attributes: ResultAttributes? = ResultAttributes(), @JsonProperty("attributes") var attributes: ResultAttributes? = ResultAttributes()
) )
@Serializable
data class ResultAttributes( data class ResultAttributes(
@JsonProperty("subtitle_id") @SerialName("subtitle_id") var subtitleId: String? = null, @JsonProperty("subtitle_id") var subtitleId: String? = null,
@JsonProperty("language") @SerialName("language") var language: String? = null, @JsonProperty("language") var language: String? = null,
@JsonProperty("release") @SerialName("release") var release: String? = null, @JsonProperty("release") var release: String? = null,
@JsonProperty("url") @SerialName("url") var url: String? = null, @JsonProperty("url") var url: String? = null,
@JsonProperty("files") @SerialName("files") var files: List<ResultFiles>? = listOf(), @JsonProperty("files") var files: List<ResultFiles>? = listOf(),
@JsonProperty("feature_details") @SerialName("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(), @JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(),
@JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Boolean? = null, @JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null,
) )
@Serializable
data class ResultFiles( data class ResultFiles(
@JsonProperty("file_id") @SerialName("file_id") var fileId: Int? = null, @JsonProperty("file_id") var fileId: Int? = null,
@JsonProperty("file_name") @SerialName("file_name") var fileName: String? = null, @JsonProperty("file_name") var fileName: String? = null
) )
@Serializable
data class ResultDownloadLink( data class ResultDownloadLink(
@JsonProperty("link") @SerialName("link") var link: String? = null, @JsonProperty("link") var link: String? = null,
@JsonProperty("file_name") @SerialName("file_name") var fileName: String? = null, @JsonProperty("file_name") var fileName: String? = null,
@JsonProperty("requests") @SerialName("requests") var requests: Int? = null, @JsonProperty("requests") var requests: Int? = null,
@JsonProperty("remaining") @SerialName("remaining") var remaining: Int? = null, @JsonProperty("remaining") var remaining: Int? = null,
@JsonProperty("message") @SerialName("message") var message: String? = null, @JsonProperty("message") var message: String? = null,
@JsonProperty("reset_time") @SerialName("reset_time") var resetTime: String? = null, @JsonProperty("reset_time") var resetTime: String? = null,
@JsonProperty("reset_time_utc") @SerialName("reset_time_utc") var resetTimeUtc: String? = null, @JsonProperty("reset_time_utc") var resetTimeUtc: String? = null
) )
@Serializable
data class ResultFeatureDetails( data class ResultFeatureDetails(
@JsonProperty("year") @SerialName("year") var year: Int? = null, @JsonProperty("year") var year: Int? = null,
@JsonProperty("title") @SerialName("title") var title: String? = null, @JsonProperty("title") var title: String? = null,
@JsonProperty("movie_name") @SerialName("movie_name") var movieName: String? = null, @JsonProperty("movie_name") var movieName: String? = null,
@JsonProperty("imdb_id") @SerialName("imdb_id") var imdbId: Int? = null, @JsonProperty("imdb_id") var imdbId: Int? = null,
@JsonProperty("tmdb_id") @SerialName("tmdb_id") var tmdbId: Int? = null, @JsonProperty("tmdb_id") var tmdbId: Int? = null,
@JsonProperty("season_number") @SerialName("season_number") var seasonNumber: Int? = null, @JsonProperty("season_number") var seasonNumber: Int? = null,
@JsonProperty("episode_number") @SerialName("episode_number") var episodeNumber: Int? = null, @JsonProperty("episode_number") var episodeNumber: Int? = null,
@JsonProperty("parent_imdb_id") @SerialName("parent_imdb_id") var parentImdbId: Int? = null, @JsonProperty("parent_imdb_id") var parentImdbId: Int? = null,
@JsonProperty("parent_title") @SerialName("parent_title") var parentTitle: String? = null, @JsonProperty("parent_title") var parentTitle: String? = null,
@JsonProperty("parent_tmdb_id") @SerialName("parent_tmdb_id") var parentTmdbId: Int? = null, @JsonProperty("parent_tmdb_id") var parentTmdbId: Int? = null,
@JsonProperty("parent_feature_id") @SerialName("parent_feature_id") var parentFeatureId: Int? = null, @JsonProperty("parent_feature_id") var parentFeatureId: Int? = null
) )
} }

View file

@ -3,202 +3,157 @@ package com.lagradost.cloudstream3.syncproviders.providers
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubProvider
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.concurrent.TimeUnit
class SubSourceApi : SubtitleAPI() { class SubSourceApi : AbstractSubProvider {
override val name = "SubSource"
override val idPrefix = "subsource" override val idPrefix = "subsource"
val name = "SubSource"
override val requiresLogin = false
companion object { companion object {
const val APIURL = "https://api.subsource.net/v1" const val APIURL = "https://api.subsource.net/api"
const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
} }
override suspend fun search( override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
auth: AuthData?,
query: AbstractSubtitleEntities.SubtitleSearch
): List<AbstractSubtitleEntities.SubtitleEntity>? {
//Only supports Imdb Id search for now //Only supports Imdb Id search for now
if (query.imdbId == null) return null if (query.imdbId == null) return null
val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
val searchResponse = app.post( val searchRes = app.post(
url = "$APIURL/movie/search", url = "$APIURL/searchMovie",
json = mapOf( data = mapOf(
"includeSeasons" to false, "query" to query.imdbId!!
"limit" to 15, )
"query" to query.imdbId!!, ).parsedSafe<ApiSearch>() ?: return null
"signal" to "{}"
), val postData = if (type == TvType.TvSeries) {
cacheTime = 120, mapOf(
cacheUnit = TimeUnit.MINUTES, "langs" to "[]",
).parsedSafe<SearchRoot>() ?: return null "movieName" to searchRes.found.first().linkName,
"season" to "season-${query.seasonNumber}"
val firstResult = searchResponse.results.firstOrNull() ?: return null )
} else {
val apiResponse = app.get( mapOf(
url = "$APIURL${firstResult.link.replace("series", "subtitles")}", "langs" to "[]",
cacheTime = 120, "movieName" to searchRes.found.first().linkName,
cacheUnit = TimeUnit.MINUTES,
).parsedSafe<ItemRoot>() ?: return null
val filteredSubtitles = apiResponse.subtitles.filter { sub ->
sub.releaseType != "trailer" &&
sub.language.equals(queryLang, true)
}
// api doesn't has episode number or lang filtering
val subtitles = if (type == TvType.Movie) {
filteredSubtitles
} else {
val shouldContain = String.format(
null,
"E%02d",
query.epNumber
) )
filteredSubtitles.filter { sub ->
sub.releaseInfo.contains(
shouldContain
)
}
} }
return subtitles.map { subtitle -> val getMovieRes = app.post(
url = "$APIURL/getMovie",
data = postData
).parsedSafe<ApiResponse>().let {
// api doesn't has episode number or lang filtering
if (type == TvType.Movie) {
it?.subs?.filter { sub ->
sub.lang == queryLang
}
} else {
it?.subs?.filter { sub ->
sub.releaseName!!.contains(
String.format(
null,
"E%02d",
query.epNumber
)
) && sub.lang == queryLang
}
}
} ?: return null
return getMovieRes.map { subtitle ->
AbstractSubtitleEntities.SubtitleEntity( AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix, idPrefix = this.idPrefix,
name = subtitle.releaseInfo, name = subtitle.releaseName!!,
lang = subtitle.language, lang = subtitle.lang!!,
data = subtitle.link, data = SubData(
movie = subtitle.linkName!!,
lang = subtitle.lang,
id = subtitle.subId.toString(),
).toJson(),
type = type, type = type,
source = this.name, source = this.name,
epNumber = query.epNumber, epNumber = query.epNumber,
seasonNumber = query.seasonNumber, seasonNumber = query.seasonNumber,
isHearingImpaired = subtitle.hearingImpaired == 1, isHearingImpaired = subtitle.hi == 1,
) )
} }
} }
override suspend fun SubtitleResource.getResources( override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
auth: AuthData?,
subtitle: AbstractSubtitleEntities.SubtitleEntity val parsedSub = parseJson<SubData>(data.data)
) {
val data = app.get("$APIURL/subtitle/${subtitle.data}") val subRes = app.post(
.parsedSafe<DownloadRoot>() url = "$APIURL/getSub",
?: return data = mapOf(
"movie" to parsedSub.movie,
"lang" to data.lang,
"id" to parsedSub.id
)
).parsedSafe<SubTitleLink>() ?: return
this.addZipUrl( this.addZipUrl(
"$APIURL/subtitle/download/${data.subtitle.downloadToken}" "$DOWNLOADENDPOINT/${subRes.sub.downloadToken}"
) { name, _ -> ) { name, _ ->
name name
} }
} }
data class ApiSearch(
@Serializable @JsonProperty("success") val success: Boolean,
data class SearchRoot( @JsonProperty("found") val found: List<Found>,
@JsonProperty("success") @SerialName("success") var success: Boolean? = null,
@JsonProperty("results") @SerialName("results") var results: ArrayList<Results> = arrayListOf(),
@JsonProperty("users") @SerialName("users") var users: ArrayList<Users> = arrayListOf()
) )
@Serializable data class Found(
data class Users( @JsonProperty("id") val id: Long,
@JsonProperty("title") val title: String,
@JsonProperty("id") @SerialName("id") var id: Int? = null, @JsonProperty("seasons") val seasons: Long,
@JsonProperty("displayname") @SerialName("displayname") var displayname: String? = null, @JsonProperty("type") val type: String,
@JsonProperty("avatar") @SerialName("avatar") var avatar: String? = null, @JsonProperty("releaseYear") val releaseYear: Long,
@JsonProperty("badges") @SerialName("badges") var badges: ArrayList<String> = arrayListOf() @JsonProperty("linkName") val linkName: String,
) )
@Serializable data class ApiResponse(
data class Results( @JsonProperty("success") val success: Boolean,
@JsonProperty("id") @SerialName("id") var id: Int? = null, @JsonProperty("movie") val movie: Movie,
@JsonProperty("title") @SerialName("title") var title: String? = null, @JsonProperty("subs") val subs: List<Sub>,
@JsonProperty("type") @SerialName("type") var type: String? = null,
@JsonProperty("link") @SerialName("link") var link: String,
@JsonProperty("releaseYear") @SerialName("releaseYear") var releaseYear: Int? = null,
@JsonProperty("poster") @SerialName("poster") var poster: String? = null,
@JsonProperty("subtitleCount") @SerialName("subtitleCount") var subtitleCount: String? = null,
@JsonProperty("rating") @SerialName("rating") var rating: Double? = null,
@JsonProperty("cast") @SerialName("cast") var cast: ArrayList<String> = arrayListOf(),
@JsonProperty("genres") @SerialName("genres") var genres: ArrayList<String> = arrayListOf(),
@JsonProperty("score") @SerialName("score") var score: Double? = null
) )
@Serializable data class Movie(
@JsonProperty("id") val id: Long? = null,
data class ItemRoot( @JsonProperty("type") val type: String? = null,
@JsonProperty("year") val year: Long? = null,
// @SerialName("media_type" ) var mediaType : String? = null, @JsonProperty("fullName") val fullName: String? = null,
@JsonProperty("subtitles") @SerialName("subtitles") var subtitles: ArrayList<Subtitles>,
//@SerialName("movie" ) var movie : Movie? = Movie()
) )
@Serializable data class Sub(
data class Subtitles( @JsonProperty("hi") val hi: Int? = null,
@JsonProperty("fullLink") val fullLink: String? = null,
@JsonProperty("id") @SerialName("id") var id: Int? = null, @JsonProperty("linkName") val linkName: String? = null,
@JsonProperty("language") @SerialName("language") var language: String, @JsonProperty("lang") val lang: String? = null,
@JsonProperty("release_type") @SerialName("release_type") var releaseType: String? = null, @JsonProperty("releaseName") val releaseName: String? = null,
@JsonProperty("release_info") @SerialName("release_info") var releaseInfo: String, @JsonProperty("subId") val subId: Long? = null,
@JsonProperty("upload_date") @SerialName("upload_date") var uploadDate: String? = null,
@JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Int? = null,
@JsonProperty("caption") @SerialName("caption") var caption: String? = null,
@JsonProperty("rating") @SerialName("rating") var rating: String? = null,
@JsonProperty("uploader_id") @SerialName("uploader_id") var uploaderId: Int? = null,
@JsonProperty("uploader_displayname") @SerialName("uploader_displayname") var uploaderDisplayname: String? = null,
@JsonProperty("uploader_badges") @SerialName("uploader_badges") var uploaderBadges: ArrayList<String> = arrayListOf(),
@JsonProperty("link") @SerialName("link") var link: String,
@JsonProperty("production_type") @SerialName("production_type") var productionType: String? = null,
@JsonProperty("last_subtitle") @SerialName("last_subtitle") var lastSubtitle: Boolean? = null
) )
@Serializable data class SubData(
data class DownloadRoot( @JsonProperty("movie") val movie: String,
@JsonProperty("subtitle") @SerialName("subtitle") var subtitle: Subtitle, @JsonProperty("lang") val lang: String,
//@SerializedName("movie" ) var movie : Movie? = Movie(), @JsonProperty("id") val id: String,
//@SerializedName("donationLinks" ) var donationLinks : DonationLinks? = DonationLinks(),
//@SerializedName("isDownloaded" ) var isDownloaded : Boolean? = null,
//@SerializedName("user_rated" ) var userRated : String? = null
) )
@Serializable data class SubTitleLink(
data class Subtitle( @JsonProperty("sub") val sub: SubToken,
@JsonProperty("id") @SerialName("id") var id: Int? = null,
@JsonProperty("uploaded_at") @SerialName("uploaded_at") var uploadedAt: String? = null,
@JsonProperty("language") @SerialName("language") var language: String? = null,
@JsonProperty("rating") @SerialName("rating") var rating: String? = null,
//SerialName("rates" ) var rates : Rates? = Rates(),
@JsonProperty("uploaded_by") @SerialName("uploaded_by") var uploadedBy: Int? = null,
//@SerialName("contribs" ) var contribs : ArrayList<Contribs> = arrayListOf(),
@JsonProperty("release_info") @SerialName("release_info") var releaseInfo: ArrayList<String> = arrayListOf(),
@JsonProperty("commentary") @SerialName("commentary") var commentary: String? = null,
@JsonProperty("files") @SerialName("files") var files: String? = null,
@JsonProperty("size") @SerialName("size") var size: String? = null,
@JsonProperty("downloads") @SerialName("downloads") var downloads: Int? = null,
@JsonProperty("comments") @SerialName("comments") var comments: Int? = null,
@JsonProperty("production_type") @SerialName("production_type") var productionType: String? = null,
@JsonProperty("release_type") @SerialName("release_type") var releaseType: String? = null,
@JsonProperty("episode") @SerialName("episode") var episode: String? = null,
@JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Int? = null,
@JsonProperty("foreign_parts") @SerialName("foreign_parts") var foreignParts: String? = null,
@JsonProperty("framerate") @SerialName("framerate") var framerate: String? = null,
@JsonProperty("preview") @SerialName("preview") var preview: String? = null,
@JsonProperty("user_uploaded") @SerialName("user_uploaded") var userUploaded: Boolean? = null,
@JsonProperty("download_token") @SerialName("download_token") var downloadToken: String
) )
}
data class SubToken(
@JsonProperty("downloadToken") val downloadToken: String,
)
}

View file

@ -1,73 +1,89 @@
package com.lagradost.cloudstream3.syncproviders.providers package com.lagradost.cloudstream3.syncproviders.providers
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthAPI.LoginInfo
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.TvType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class SubDlApi : SubtitleAPI() { class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
override val name = "SubDL"
override val idPrefix = "subdl" override val idPrefix = "subdl"
override val name = "SubDL"
override val icon = R.drawable.subdl_logo_big override val icon = R.drawable.subdl_logo_big
override val hasInApp = true override val requiresPassword = true
override val inAppLoginRequirement = AuthLoginRequirement(password = true, email = true) override val requiresEmail = true
override val requiresLogin = true
override val createAccountUrl = "https://subdl.com/panel/register" override val createAccountUrl = "https://subdl.com/panel/register"
companion object { companion object {
const val APIURL = "https://api.subdl.com" const val APIURL = "https://apiold.subdl.com"
const val APIENDPOINT = "$APIURL/api/v1/subtitles" const val APIENDPOINT = "$APIURL/api/v1/subtitles"
const val DOWNLOADENDPOINT = "https://dl.subdl.com" const val DOWNLOADENDPOINT = "https://dl.subdl.com"
const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user"
var currentSession: SubtitleOAuthEntity? = null
} }
override suspend fun login(form: AuthLoginResponse): AuthToken? { override suspend fun initialize() {
val email = form.email ?: return null currentSession = getAuthKey()
val password = form.password ?: return null }
val tokenResponse = app.post(
url = "$APIURL/login", override fun logOut() {
json = mapOf( setAuthKey(null)
"email" to email, removeAccountKeys()
"password" to password currentSession = getAuthKey()
}
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
val email = data.email ?: throw ErrorLoadingException("Requires Email")
val password = data.password ?: throw ErrorLoadingException("Requires Password")
switchToNewAccount()
try {
if (initLogin(email, password)) {
registerAccount()
return true
}
} catch (e: Exception) {
logError(e)
switchToOldAccount()
}
switchToOldAccount()
return false
}
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
val current = getAuthKey() ?: return null
return InAppAuthAPI.LoginData(
email = current.userEmail,
password = current.pass
)
}
override fun loginInfo(): LoginInfo? {
getAuthKey()?.let { user ->
return LoginInfo(
profilePicture = null,
name = user.name ?: user.userEmail,
accountIndex = accountIndex
) )
).parsed<OAuthTokenResponse>() }
return null
val apiResponse = app.get(
url = "$APIURL/user/userApi",
headers = mapOf(
"Authorization" to "Bearer ${tokenResponse.token}"
)
).parsed<ApiKeyResponse>()
return AuthToken(accessToken = apiResponse.apiKey, payload = email)
} }
override suspend fun user(token: AuthToken?): AuthUser? { override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
val name = token?.payload ?: return null
return AuthUser(id = name.hashCode(), name = name)
}
override suspend fun search(
auth : AuthData?,
query: AbstractSubtitleEntities.SubtitleSearch
): List<AbstractSubtitleEntities.SubtitleEntity>? {
if (auth == null) return null
val apiKey = auth.token.accessToken ?: return null
val queryText = query.query val queryText = query.query
val epNum = query.epNumber ?: 0 val epNum = query.epNumber ?: 0
val seasonNum = query.seasonNumber ?: 0 val seasonNum = query.seasonNumber ?: 0
val yearNum = query.year ?: 0 val yearNum = query.year ?: 0
val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang
val idQuery = when { val idQuery = when {
query.imdbId != null -> "&imdb_id=${query.imdbId}" query.imdbId != null -> "&imdb_id=${query.imdbId}"
@ -81,8 +97,8 @@ class SubDlApi : SubtitleAPI() {
val searchQueryUrl = when (idQuery) { val searchQueryUrl = when (idQuery) {
//Use imdb/tmdb id to search if its valid //Use imdb/tmdb id to search if its valid
null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" null -> "$APIENDPOINT?api_key=${currentSession?.apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" else -> "$APIENDPOINT?api_key=${currentSession?.apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
} }
val req = app.get( val req = app.get(
@ -94,9 +110,7 @@ class SubDlApi : SubtitleAPI() {
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle -> return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
val langTagIETF = val lang = subtitle.lang.replaceFirstChar { it.uppercase() }
langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?:
subtitle.lang
val resEpNum = subtitle.episode ?: query.epNumber val resEpNum = subtitle.episode ?: query.epNumber
val resSeasonNum = subtitle.season ?: query.seasonNumber val resSeasonNum = subtitle.season ?: query.seasonNumber
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
@ -104,7 +118,7 @@ class SubDlApi : SubtitleAPI() {
AbstractSubtitleEntities.SubtitleEntity( AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix, idPrefix = this.idPrefix,
name = subtitle.releaseName, name = subtitle.releaseName,
lang = langTagIETF, lang = lang,
data = "${DOWNLOADENDPOINT}${subtitle.url}", data = "${DOWNLOADENDPOINT}${subtitle.url}",
type = type, type = type,
source = this.name, source = this.name,
@ -115,155 +129,120 @@ class SubDlApi : SubtitleAPI() {
} }
} }
override suspend fun SubtitleResource.getResources( override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
auth: AuthData?, this.addZipUrl(data.data) { name, _ ->
subtitle: AbstractSubtitleEntities.SubtitleEntity
) {
this.addZipUrl(subtitle.data) { name, _ ->
name name
} }
} }
@Serializable private suspend fun initLogin(useremail: String, password: String): Boolean {
val tokenResponse = app.post(
url = "$APIURL/login",
json = mapOf(
"email" to useremail,
"password" to password
)
).parsedSafe<OAuthTokenResponse>()
if (tokenResponse?.token == null) return false
val apiResponse = app.get(
url = "$APIURL/user/userApi",
headers = mapOf(
"Authorization" to "Bearer ${tokenResponse.token}"
)
).parsedSafe<ApiKeyResponse>()
if (apiResponse?.ok == false) return false
setAuthKey(
SubtitleOAuthEntity(
userEmail = useremail,
pass = password,
name = tokenResponse.userData?.username ?: tokenResponse.userData?.name,
accessToken = tokenResponse.token,
apiKey = apiResponse?.apiKey
)
)
return true
}
private fun getAuthKey(): SubtitleOAuthEntity? {
return getKey(accountId, SUBDL_SUBTITLES_USER_KEY)
}
private fun setAuthKey(data: SubtitleOAuthEntity?) {
if (data == null) removeKey(
accountId,
SUBDL_SUBTITLES_USER_KEY
)
currentSession = data
setKey(accountId, SUBDL_SUBTITLES_USER_KEY, data)
}
data class SubtitleOAuthEntity( data class SubtitleOAuthEntity(
@JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String, @JsonProperty("userEmail") var userEmail: String,
@JsonProperty("pass") @SerialName("pass") var pass: String, @JsonProperty("pass") var pass: String,
@JsonProperty("name") @SerialName("name") var name: String? = null, @JsonProperty("name") var name: String? = null,
@JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null, @JsonProperty("accessToken") var accessToken: String? = null,
@JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null, @JsonProperty("apiKey") var apiKey: String? = null,
) )
@Serializable
data class OAuthTokenResponse( data class OAuthTokenResponse(
@JsonProperty("token") @SerialName("token") val token: String, @JsonProperty("token") val token: String? = null,
@JsonProperty("userData") @SerialName("userData") val userData: UserData? = null, @JsonProperty("userData") val userData: UserData? = null,
@JsonProperty("status") @SerialName("status") val status: Boolean? = null, @JsonProperty("status") val status: Boolean? = null,
@JsonProperty("message") @SerialName("message") val message: String? = null, @JsonProperty("message") val message: String? = null,
) )
@Serializable
data class UserData( data class UserData(
@JsonProperty("email") @SerialName("email") val email: String, @JsonProperty("email") val email: String,
@JsonProperty("name") @SerialName("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("country") @SerialName("country") val country: String, @JsonProperty("country") val country: String,
@JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String, @JsonProperty("scStepCode") val scStepCode: String,
@JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean, @JsonProperty("scVerified") val scVerified: Boolean,
@JsonProperty("username") @SerialName("username") val username: String? = null, @JsonProperty("username") val username: String? = null,
@JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String, @JsonProperty("scUsername") val scUsername: String,
) )
@Serializable
data class ApiKeyResponse( data class ApiKeyResponse(
@JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false, @JsonProperty("ok") val ok: Boolean? = false,
@JsonProperty("api_key") @SerialName("api_key") val apiKey: String, @JsonProperty("api_key") val apiKey: String? = null,
@JsonProperty("usage") @SerialName("usage") val usage: Usage? = null, @JsonProperty("usage") val usage: Usage? = null,
) )
@Serializable
data class Usage( data class Usage(
@JsonProperty("total") @SerialName("total") val total: Long? = 0, @JsonProperty("total") val total: Long? = 0,
@JsonProperty("today") @SerialName("today") val today: Long? = 0, @JsonProperty("today") val today: Long? = 0,
) )
@Serializable
data class ApiResponse( data class ApiResponse(
@JsonProperty("status") @SerialName("status") val status: Boolean? = null, @JsonProperty("status") val status: Boolean? = null,
@JsonProperty("results") @SerialName("results") val results: List<Result>? = null, @JsonProperty("results") val results: List<Result>? = null,
@JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List<Subtitle>? = null, @JsonProperty("subtitles") val subtitles: List<Subtitle>? = null,
) )
@Serializable
data class Result( data class Result(
@JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null, @JsonProperty("sd_id") val sdId: Int? = null,
@JsonProperty("type") @SerialName("type") val type: String? = null, @JsonProperty("type") val type: String? = null,
@JsonProperty("name") @SerialName("name") val name: String? = null, @JsonProperty("name") val name: String? = null,
@JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null, @JsonProperty("imdb_id") val imdbId: String? = null,
@JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null, @JsonProperty("tmdb_id") val tmdbId: Long? = null,
@JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null, @JsonProperty("first_air_date") val firstAirDate: String? = null,
@JsonProperty("year") @SerialName("year") val year: Int? = null, @JsonProperty("year") val year: Int? = null,
) )
@Serializable
data class Subtitle( data class Subtitle(
@JsonProperty("release_name") @SerialName("release_name") val releaseName: String, @JsonProperty("release_name") val releaseName: String,
@JsonProperty("name") @SerialName("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code @JsonProperty("lang") val lang: String,
@JsonProperty("author") @SerialName("author") val author: String? = null, @JsonProperty("author") val author: String? = null,
@JsonProperty("url") @SerialName("url") val url: String? = null, @JsonProperty("url") val url: String? = null,
@JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null, @JsonProperty("subtitlePage") val subtitlePage: String? = null,
@JsonProperty("season") @SerialName("season") val season: Int? = null, @JsonProperty("season") val season: Int? = null,
@JsonProperty("episode") @SerialName("episode") val episode: Int? = null, @JsonProperty("episode") val episode: Int? = null,
@JsonProperty("language") @SerialName("language") val language: String? = null, // full language name @JsonProperty("language") val language: String? = null,
@JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null, @JsonProperty("hi") val hearingImpaired: Boolean? = null,
)
// https://subdl.com/api-files/language_list.json
// most of it is IETF BPC 47 conformant tag
// but there are some exceptions
private val langTagIETF2subdl = mapOf(
"en-bg" to "BG_EN", // "Bulgarian_English"
"en-de" to "EN_DE", // "English_German"
"en-hu" to "HU_EN", // "Hungarian_English"
"en-nl" to "NL_EN", // "Dutch_English"
"pt-br" to "BR_PT", // "Brazillian Portuguese"
"zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?)
// "ar" to "AR", // "Arabic"
// "az" to "AZ", // "Azerbaijani"
// "be" to "BE", // "Belarusian"
// "bg" to "BG", // "Bulgarian"
// "bn" to "BN", // "Bengali"
// "bs" to "BS", // "Bosnian"
// "ca" to "CA", // "Catalan"
// "cs" to "CS", // "Czech"
// "da" to "DA", // "Danish"
// "de" to "DE", // "German"
// "el" to "EL", // "Greek"
// "en" to "EN", // "English"
// "eo" to "EO", // "Esperanto"
// "es" to "ES", // "Spanish"
// "et" to "ET", // "Estonian"
// "fa" to "FA", // "Farsi_Persian"
// "fi" to "FI", // "Finnish"
// "fr" to "FR", // "French"
// "he" to "HE", // "Hebrew"
// "hi" to "HI", // "Hindi"
// "hr" to "HR", // "Croatian"
// "hu" to "HU", // "Hungarian"
// "id" to "ID", // "Indonesian"
// "is" to "IS", // "Icelandic"
// "it" to "IT", // "Italian"
// "ja" to "JA", // "Japanese"
// "ka" to "KA", // "Georgian"
// "kl" to "KL", // "Greenlandic"
// "ko" to "KO", // "Korean"
// "ku" to "KU", // "Kurdish"
// "lt" to "LT", // "Lithuanian"
// "lv" to "LV", // "Latvian"
// "mk" to "MK", // "Macedonian"
// "ml" to "ML", // "Malayalam"
// "mni" to "MNI", // "Manipuri"
// "ms" to "MS", // "Malay"
// "my" to "MY", // "Burmese"
// "nl" to "NL", // "Dutch"
// "no" to "NO", // "Norwegian"
// "pl" to "PL", // "Polish"
// "pt" to "PT", // "Portuguese"
// "ro" to "RO", // "Romanian"
// "ru" to "RU", // "Russian"
// "si" to "SI", // "Sinhala"
// "sk" to "SK", // "Slovak"
// "sl" to "SL", // "Slovenian"
// "sq" to "SQ", // "Albanian"
// "sr" to "SR", // "Serbian"
// "sv" to "SV", // "Swedish"
// "ta" to "TA", // "Tamil"
// "te" to "TE", // "Telugu"
// "th" to "TH", // "Thai"
// "tl" to "TL", // "Tagalog"
// "tr" to "TR", // "Turkish"
// "uk" to "UK", // "Ukranian"
// "ur" to "UR", // "Urdu"
// "vi" to "VI", // "Vietnamese"
// "zh" to "ZH", // "Chinese BG code"
) )
} }

View file

@ -9,29 +9,23 @@ import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.MainPageRequest
import com.lagradost.cloudstream3.SearchResponseList import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.fixUrl import com.lagradost.cloudstream3.fixUrl
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.newSearchResponseList import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope.coroutineContext
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
class APIRepository(val api: MainAPI) { class APIRepository(val api: MainAPI) {
companion object { companion object {
// 2 minute timeout to prevent bad extensions/extractors from hogging the resources
// No real provider should take longer, so we hard kill them.
private const val DEFAULT_TIMEOUT = 120_000L
private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT
private const val MIN_TIMEOUT = 5_000L
var dubStatusActive = HashSet<DubStatus>() var dubStatusActive = HashSet<DubStatus>()
val noneApi = object : MainAPI() { val noneApi = object : MainAPI() {
@ -55,18 +49,16 @@ class APIRepository(val api: MainAPI) {
val hash: Pair<String, String> val hash: Pair<String, String>
) )
private val cache = atomicListOf<SavedLoadResponse>() private val cache = threadSafeListOf<SavedLoadResponse>()
private var cacheIndex: Int = 0 private var cacheIndex: Int = 0
const val CACHE_SIZE = 20 const val CACHE_SIZE = 20
fun getTimeout(desired: Long?): Long {
return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT)
}
} }
private fun afterPluginsLoaded(forceReload: Boolean) { private fun afterPluginsLoaded(forceReload: Boolean) {
if (forceReload) { if (forceReload) {
cache.clear() synchronized(cache) {
cache.clear()
}
} }
} }
@ -84,66 +76,54 @@ class APIRepository(val api: MainAPI) {
suspend fun load(url: String): Resource<LoadResponse> { suspend fun load(url: String): Resource<LoadResponse> {
return safeApiCall { return safeApiCall {
withTimeout(getTimeout(api.loadTimeoutMs)) { if (isInvalidData(url)) throw ErrorLoadingException()
if (isInvalidData(url)) throw ErrorLoadingException() val fixedUrl = api.fixUrl(url)
val fixedUrl = api.fixUrl(url) val lookingForHash = Pair(api.name, fixedUrl)
val lookingForHash = Pair(api.name, fixedUrl)
val cached = cache.withLock { synchronized(cache) {
var found: LoadResponse? = null for (item in cache) {
for (item in cache) { // 10 min save
// 10 min save if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { return@safeApiCall item.response
found = item.response
break
}
} }
found
} }
}
if (cached != null) return@withTimeout cached api.load(fixedUrl)?.also { response ->
api.load(fixedUrl)?.also { response -> // Remove all blank tags as early as possible
// Remove all blank tags as early as possible response.tags = response.tags?.filter { it.isNotBlank() }
response.tags = response.tags?.filter { it.isNotBlank() } val add = SavedLoadResponse(unixTime, response, lookingForHash)
val add = SavedLoadResponse(unixTime, response, lookingForHash)
cache.withLock { synchronized(cache) {
if (cache.size > CACHE_SIZE) { if (cache.size > CACHE_SIZE) {
cache[cacheIndex] = add // rolling cache cache[cacheIndex] = add // rolling cache
cacheIndex = (cacheIndex + 1) % CACHE_SIZE cacheIndex = (cacheIndex + 1) % CACHE_SIZE
} else { } else {
cache.add(add) cache.add(add)
}
} }
} ?: throw ErrorLoadingException() }
} } ?: throw ErrorLoadingException()
} }
} }
suspend fun search(query: String, page: Int): Resource<SearchResponseList> { suspend fun search(query: String): Resource<List<SearchResponse>> {
if (query.isEmpty()) if (query.isEmpty())
return Resource.Success(newSearchResponseList(emptyList())) return Resource.Success(emptyList())
return safeApiCall { return safeApiCall {
withTimeout(getTimeout(api.searchTimeoutMs)) { return@safeApiCall (api.search(query)
(api.search(query, page) ?: throw ErrorLoadingException())
?: throw ErrorLoadingException()) // .filter { typesActive.contains(it.type) }
// .filter { typesActive.contains(it.type) } .toList()
}
} }
} }
suspend fun quickSearch(query: String): Resource<SearchResponseList> { suspend fun quickSearch(query: String): Resource<List<SearchResponse>> {
if (query.isEmpty()) if (query.isEmpty())
return Resource.Success(newSearchResponseList(emptyList())) return Resource.Success(emptyList())
return safeApiCall { return safeApiCall {
withTimeout(getTimeout(api.quickSearchTimeoutMs)) { api.quickSearch(query) ?: throw ErrorLoadingException()
newSearchResponseList(
api.quickSearch(query) ?: throw ErrorLoadingException(),
false
)
}
} }
} }
@ -153,42 +133,41 @@ class APIRepository(val api: MainAPI) {
delay(delta) delay(delta)
} }
@OptIn(DelicateCoroutinesApi::class)
suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource<List<HomePageResponse?>> { suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource<List<HomePageResponse?>> {
return safeApiCall { return safeApiCall {
withTimeout(getTimeout(api.getMainPageTimeoutMs)) { api.lastHomepageRequest = unixTimeMS
api.lastHomepageRequest = unixTimeMS
nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data ->
listOf(
api.getMainPage(
page,
MainPageRequest(data.name, data.data, data.horizontalImages)
)
)
} ?: run {
if (api.sequentialMainPage) {
var first = true
api.mainPage.map { data ->
if (!first) // dont want to sleep on first request
delay(api.sequentialMainPageDelay)
first = false
nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data ->
listOf(
api.getMainPage( api.getMainPage(
page, page,
MainPageRequest(data.name, data.data, data.horizontalImages) MainPageRequest(data.name, data.data, data.horizontalImages)
) )
) }
} ?: run { } else {
if (api.sequentialMainPage) { with(CoroutineScope(coroutineContext)) {
var first = true
api.mainPage.map { data -> api.mainPage.map { data ->
if (!first) // dont want to sleep on first request async {
delay(api.sequentialMainPageDelay) api.getMainPage(
first = false page,
MainPageRequest(data.name, data.data, data.horizontalImages)
api.getMainPage( )
page, }
MainPageRequest(data.name, data.data, data.horizontalImages) }.map { it.await() }
)
}
} else {
with(CoroutineScope(coroutineContext)) {
api.mainPage.map { data ->
async {
api.getMainPage(
page,
MainPageRequest(data.name, data.data, data.horizontalImages)
)
}
}.map { it.await() }
}
} }
} }
} }
@ -209,12 +188,10 @@ class APIRepository(val api: MainAPI) {
): Boolean { ): Boolean {
if (isInvalidData(data)) return false // this makes providers cleaner if (isInvalidData(data)) return false // this makes providers cleaner
return try { return try {
withTimeout(getTimeout(api.loadLinksTimeoutMs)) { api.loadLinks(data, isCasting, subtitleCallback, callback)
api.loadLinks(data, isCasting, subtitleCallback, callback)
}
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
logError(throwable) logError(throwable)
return false return false
} }
} }
} }

View file

@ -1,55 +1,34 @@
package com.lagradost.cloudstream3.ui package com.lagradost.cloudstream3.ui
import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.view.children import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import coil3.dispose
import java.util.WeakHashMap
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) { open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
open fun save(): T? = null open fun save(): T? = null
open fun restore(state: T) = Unit open fun restore(state: T) = Unit
open fun onViewAttachedToWindow() = Unit
open fun onViewDetachedFromWindow() = Unit
open fun onViewRecycled() = Unit
} }
abstract class NoStateAdapter<T : Any>(
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
) : BaseAdapter<T, Any>(0, diffCallback)
/** Creates a new shared pool, using the supplied lambda as a constructor. // Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154
* class StateViewModel : ViewModel() {
* The reason for this complicated structure is that a pool should not be shared between contexts val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
* as it makes coil fuck up, and theming.
* */
fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit> =
WeakHashMap<Context, RecyclerView.RecycledViewPool>() to lambda
/** Sets the shared pool of the recyclerview */
fun RecyclerView.setRecycledViewPool(pool: Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>) {
val ctx = context ?: return
synchronized(pool.first) {
this.setRecycledViewPool(pool.first.getOrPut(ctx) {
RecyclerView.RecycledViewPool().apply(pool.second)
})
}
} }
/** Clears the shared pool of views */ abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>(fragment, 0)
fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>.clear() {
synchronized(this.first) {
for (pool in this.first.values) {
pool?.clear()
}
}
}
/** /**
* BaseAdapter is a persistent state stored adapter that supports headers and footers. * BaseAdapter is a persistent state stored adapter that supports headers and footers.
@ -70,14 +49,13 @@ fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.Recyc
abstract class BaseAdapter< abstract class BaseAdapter<
T : Any, T : Any,
S : Any>( S : Any>(
fragment: Fragment,
val id: Int = 0, val id: Int = 0,
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback() diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
) : RecyclerView.Adapter<ViewHolderState<S>>() { ) : RecyclerView.Adapter<ViewHolderState<S>>() {
open val footers: Int = 0 open val footers: Int = 0
open val headers: Int = 0 open val headers: Int = 0
val immutableCurrentList: List<T> get() = mDiffer.currentList
fun getItem(position: Int): T { fun getItem(position: Int): T {
return mDiffer.currentList[position] return mDiffer.currentList[position]
} }
@ -107,33 +85,9 @@ abstract class BaseAdapter<
AsyncDifferConfig.Builder(diffCallback).build() AsyncDifferConfig.Builder(diffCallback).build()
) )
/** open fun submitList(list: List<T>?) {
* Instantly submits a **new and fresh** list. This means that no changes like moves are done as
* we assume the new list is not the same thing as the old list, nothing is shared.
*
* The views are rendered instantly as a result, so no fade/pop-ins or similar.
*
* Use `submitList` for general use, as that can reuse old views.
* */
open fun submitIncomparableList(list: List<T>?, commitCallback : Runnable? = null) {
// This leverages a quirk in the submitList function that has a fast case for null arrays
// What this implies is that as long as we do a double submit we can ensure no pop-ins,
// as the changes are the entire list instead of calculating deltas
submitList(null)
submitList(list, commitCallback)
}
/**
* @param commitCallback Optional runnable that is executed when the List is committed, if it is committed.
* This is needed for some tasks as submitList will use a background thread for diff
* */
open fun submitList(list: Collection<T>?, commitCallback : Runnable? = null) {
// deep copy at least the top list, because otherwise adapter can go crazy // deep copy at least the top list, because otherwise adapter can go crazy
if (list.isNullOrEmpty()) { mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList()
} else {
mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback)
}
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
@ -147,25 +101,16 @@ abstract class BaseAdapter<
open fun onBindFooter(holder: ViewHolderState<S>) = Unit open fun onBindFooter(holder: ViewHolderState<S>) = Unit
open fun onBindHeader(holder: ViewHolderState<S>) = Unit open fun onBindHeader(holder: ViewHolderState<S>) = Unit
open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError() open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomContent(
parent: ViewGroup,
viewType: Int
) = onCreateContent(parent)
open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError() open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomFooter(
parent: ViewGroup,
viewType: Int
) = onCreateFooter(parent)
open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError() open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomHeader(
parent: ViewGroup,
viewType: Int
) = onCreateHeader(parent)
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {} override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {} holder.onViewAttachedToWindow()
}
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {
holder.onViewDetachedFromWindow()
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun save(recyclerView: RecyclerView) { fun save(recyclerView: RecyclerView) {
@ -176,20 +121,21 @@ abstract class BaseAdapter<
} }
} }
fun clearState() { fun clear() {
layoutManagerStates[id]?.clear() stateViewModel.layoutManagerStates[id]?.clear()
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun getState(holder: ViewHolderState<S>): S? = private fun getState(holder: ViewHolderState<S>): S? =
layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
private fun setState(holder: ViewHolderState<S>) { private fun setState(holder: ViewHolderState<S>) {
if (id == 0) return if(id == 0) return
if (!layoutManagerStates.contains(id)) {
layoutManagerStates[id] = HashMap() if (!stateViewModel.layoutManagerStates.contains(id)) {
stateViewModel.layoutManagerStates[id] = HashMap()
} }
layoutManagerStates[id]?.let { map -> stateViewModel.layoutManagerStates[id]?.let { map ->
map[holder.absoluteAdapterPosition] = holder.save() map[holder.absoluteAdapterPosition] = holder.save()
} }
} }
@ -212,40 +158,30 @@ abstract class BaseAdapter<
super.onDetachedFromRecyclerView(recyclerView) super.onDetachedFromRecyclerView(recyclerView)
} }
open fun customContentViewType(item: T): Int = 0
open fun customFooterViewType(): Int = 0
open fun customHeaderViewType(): Int = 0
final override fun getItemViewType(position: Int): Int { final override fun getItemViewType(position: Int): Int {
if (position < headers) { if (position < headers) {
return HEADER or customHeaderViewType() return HEADER
} }
val realPosition = position - headers if (position - headers >= mDiffer.currentList.size) {
if (realPosition >= mDiffer.currentList.size) { return FOOTER
return FOOTER or customFooterViewType()
} }
return CONTENT or customContentViewType(getItem(realPosition))
return CONTENT
} }
private val stateViewModel: StateViewModel by fragment.viewModels()
final override fun onViewRecycled(holder: ViewHolderState<S>) { final override fun onViewRecycled(holder: ViewHolderState<S>) {
setState(holder) setState(holder)
onClearView(holder) holder.onViewRecycled()
super.onViewRecycled(holder) super.onViewRecycled(holder)
} }
/** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data.
*
* If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources.
*
* Use this with `clearImage`
* */
open fun onClearView(holder: ViewHolderState<S>) {}
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> { final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
return when (viewType and TYPE_MASK) { return when (viewType) {
CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK) CONTENT -> onCreateContent(parent)
HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK) HEADER -> onCreateHeader(parent)
FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK) FOOTER -> onCreateFooter(parent)
else -> throw NotImplementedError() else -> throw NotImplementedError()
} }
} }
@ -260,7 +196,7 @@ abstract class BaseAdapter<
super.onBindViewHolder(holder, position, payloads) super.onBindViewHolder(holder, position, payloads)
return return
} }
when (getItemViewType(position) and TYPE_MASK) { when (getItemViewType(position)) {
CONTENT -> { CONTENT -> {
val realPosition = position - headers val realPosition = position - headers
val item = getItem(realPosition) val item = getItem(realPosition)
@ -278,7 +214,7 @@ abstract class BaseAdapter<
} }
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) { final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
when (getItemViewType(position) and TYPE_MASK) { when (getItemViewType(position)) {
CONTENT -> { CONTENT -> {
val realPosition = position - headers val realPosition = position - headers
val item = getItem(realPosition) val item = getItem(realPosition)
@ -300,20 +236,9 @@ abstract class BaseAdapter<
} }
companion object { companion object {
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>() private const val HEADER: Int = 1
fun clearImage(image: ImageView?) { private const val FOOTER: Int = 2
image?.dispose() private const val CONTENT: Int = 0
}
// Use the lowermost MASK_SIZE bits for the custom content,
// use the uppermost 32 - MASK_SIZE to the type
private const val MASK_SIZE = 28
private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1
private const val TYPE_MASK = CUSTOM_MASK.inv()
const val HEADER: Int = 3 shl MASK_SIZE
const val FOOTER: Int = 2 shl MASK_SIZE
/** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */
const val CONTENT: Int = 1 shl MASK_SIZE
} }
} }
@ -323,5 +248,5 @@ class BaseDiffCallback<T : Any>(
) : DiffUtil.ItemCallback<T>() { ) : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem) override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem) override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem)
override fun getChangePayload(oldItem: T, newItem: T): Any? = Any() override fun getChangePayload(oldItem: T, newItem: T): Any = Any()
} }

View file

@ -1,278 +0,0 @@
package com.lagradost.cloudstream3.ui
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding
import com.lagradost.cloudstream3.utils.txt
/**
* A base Fragment class that simplifies ViewBinding usage and handles view inflation safely.
*
* This class allows two modes of creating ViewBinding:
* 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes.
* 2. Bind: Using `bind()` on an existing root view.
*
* It also provides hooks for:
* - Safe initialization of the binding (`onBindingCreated`)
* - Automatic padding adjustment for system bars (`fixPadding`)
* - Optional layout resource selection via `pickLayout()`
*
* @param T The type of ViewBinding for this Fragment.
* @param bindingCreator The strategy used to create the binding instance.
*/
private interface BaseFragmentHelper<T : ViewBinding> {
val bindingCreator: BaseFragment.BindingCreator<T>
var _binding: T?
val binding: T? get() = _binding
fun createBinding(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val layoutId = pickLayout()
val root: View? = layoutId?.let { inflater.inflate(it, container, false) }
_binding = try {
when (val creator = bindingCreator) {
is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false)
is BaseFragment.BindingCreator.Bind -> {
if (root != null) creator.fn(root)
else throw IllegalStateException("Root view is null for bind()")
}
}
} catch (t: Throwable) {
showToast(
txt(R.string.unable_to_inflate, t.message ?: ""),
Toast.LENGTH_LONG
)
logError(t)
null
}
return _binding?.root ?: root
}
/**
* Called after the fragment's view has been created.
*
* This method is `final` to ensure that the binding is properly initialized and
* system bar padding adjustments are applied before any subclass logic runs.
* Subclasses should use [onBindingCreated] instead of overriding this method directly.
*/
fun onViewReady(view: View, savedInstanceState: Bundle?) {
fixLayout(view)
binding?.let { onBindingCreated(it, savedInstanceState) }
}
/**
* Called when the binding is safely created and view is ready.
* Can be overridden to provide fragment-specific initialization.
*
* @param binding The safely created ViewBinding.
* @param savedInstanceState Saved state bundle or null.
*/
fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
onBindingCreated(binding)
}
/**
* Called when the binding is safely created and view is ready.
* Overload without savedInstanceState for convenience.
*
* @param binding The safely created ViewBinding.
*/
fun onBindingCreated(binding: T) {}
/**
* Pick a layout resource ID for the fragment.
*
* Return `null` by default. Override to provide a layout resource when using
* `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`.
*
* @return Layout resource ID or null.
*/
@LayoutRes
fun pickLayout(): Int? = null
/**
* Ensures the layout of the root view is correctly adjusted for the current configuration.
*
* This may include applying padding for system bars, adjusting insets, or performing other
* layout updates. `fixLayout` should remain idempotent, as it can be called multiple
* times on the same view, such as during configuration changes (e.g. device rotation) or when
* the view is recreated.
*
* @param view The root view to adjust.
*/
fun fixLayout(view: View)
}
abstract class BaseFragment<T : ViewBinding>(
override val bindingCreator: BindingCreator<T>
) : Fragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
/** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */
fun dispatchBackPressed() {
try {
activity?.onBackPressedDispatcher?.onBackPressed()
} catch (_: IllegalStateException) {
// FragmentManager is already executing transactions, so try again
delayedDispatchBackPressed(5)
} catch (t: Throwable) {
logError(t)
}
}
/** Recursive back press when available */
private fun delayedDispatchBackPressed(remaining: Int) {
if (remaining <= 0) return
binding?.root?.postDelayed({
try {
activity?.onBackPressedDispatcher?.onBackPressed()
} catch (_: IllegalStateException) {
// FragmentManager is already executing transactions, so try again
delayedDispatchBackPressed(remaining - 1)
} catch (t: Throwable) {
logError(t)
}
}, 200)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/**
* Called when the device configuration changes (e.g., orientation).
* Re-applies system bar padding fixes to the root view to ensure it
* readjusts for orientation changes.
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Sealed class representing the two strategies for creating a ViewBinding instance.
*/
sealed class BindingCreator<T : ViewBinding> {
/**
* Use the standard inflate() method for creating the binding.
*
* @param fn Lambda that inflates the binding.
*/
class Inflate<T : ViewBinding>(
val fn: (LayoutInflater, ViewGroup?, Boolean) -> T
) : BindingCreator<T>()
/**
* Use bind() on an existing root view to create the binding. This should
* be used if you are differing per device layouts, such as different
* layouts for TV and Phone.
*
* @param fn Lambda that binds the root view.
*/
class Bind<T : ViewBinding>(
val fn: (View) -> T
) : BindingCreator<T>()
}
}
abstract class BaseDialogFragment<T : ViewBinding>(
override val bindingCreator: BaseFragment.BindingCreator<T>
) : DialogFragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/** @see [BaseFragment.onConfigurationChanged] for documentation. */
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
abstract class BaseBottomSheetDialogFragment<T : ViewBinding>(
override val bindingCreator: BaseFragment.BindingCreator<T>
) : BottomSheetDialogFragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/** @see [BaseFragment.onConfigurationChanged] for documentation. */
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setSystemBarsPadding()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setSystemBarsPadding()
}
}

View file

@ -12,7 +12,9 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ListView import android.widget.ListView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaLoadOptions
import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaSeekOptions
@ -35,24 +37,35 @@ import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks
import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.json.JSONObject import org.json.JSONObject
/*class SkipOpController(val view: ImageView) : UIController() {
init {
view.setImageResource(R.drawable.exo_controls_fastforward)
view.setOnClickListener {
remoteMediaClient?.let {
val options = MediaSeekOptions.Builder()
.setPosition(it.approximateStreamPosition + 85000)
it.seek(options.build())
}
}
}
}*/
private fun RemoteMediaClient.getItemIndex(): Int? { private fun RemoteMediaClient.getItemIndex(): Int? {
return try { return try {
val index = this.mediaQueue.itemIds.indexOf(this.currentItem?.itemId ?: 0) val index = this.mediaQueue.itemIds.indexOf(this.currentItem?.itemId ?: 0)
if (index < 0) null else index if (index < 0) null else index
} catch (_: Exception) { } catch (e: Exception) {
null null
} }
} }
@ -79,41 +92,51 @@ class SkipNextEpisodeController(val view: ImageView) : UIController() {
} }
} }
@Serializable
data class MetadataHolder( data class MetadataHolder(
@JsonProperty("apiName") @SerialName("apiName") val apiName: String, val apiName: String,
@JsonProperty("isMovie") @SerialName("isMovie") val isMovie: Boolean, val isMovie: Boolean,
@JsonProperty("title") @SerialName("title") val title: String?, val title: String?,
@JsonProperty("poster") @SerialName("poster") val poster: String?, val poster: String?,
@JsonProperty("currentEpisodeIndex") @SerialName("currentEpisodeIndex") val currentEpisodeIndex: Int, val currentEpisodeIndex: Int,
@JsonProperty("episodes") @SerialName("episodes") val episodes: List<ResultEpisode>, val episodes: List<ResultEpisode>,
@JsonProperty("currentLinks") @SerialName("currentLinks") val currentLinks: List<ExtractorLink>, val currentLinks: List<ExtractorLink>,
@JsonProperty("currentSubtitles") @SerialName("currentSubtitles") val currentSubtitles: List<SubtitleData>, val currentSubtitles: List<SubtitleData>
) )
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
UIController() {
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
init { init {
view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
view.setOnClickListener { view.setOnClickListener {
// lateinit var dialog: AlertDialog
val holder = getCurrentMetaData() val holder = getCurrentMetaData()
if (holder != null) { if (holder != null) {
val items = holder.currentLinks val items = holder.currentLinks
if (items.isNotEmpty() && remoteMediaClient?.currentItem != null) { if (items.isNotEmpty() && remoteMediaClient?.currentItem != null) {
val subTracks = remoteMediaClient?.mediaInfo?.mediaTracks?.filter { it.type == MediaTrack.TYPE_TEXT } val subTracks =
?: ArrayList() remoteMediaClient?.mediaInfo?.mediaTracks?.filter { it.type == MediaTrack.TYPE_TEXT }
?: ArrayList()
val bottomSheetDialogBuilder = AlertDialog.Builder(view.context, R.style.AlertDialogCustomBlack) val bottomSheetDialogBuilder =
AlertDialog.Builder(view.context, R.style.AlertDialogCustomBlack)
bottomSheetDialogBuilder.setView(R.layout.sort_bottom_sheet) bottomSheetDialogBuilder.setView(R.layout.sort_bottom_sheet)
val bottomSheetDialog = bottomSheetDialogBuilder.create() val bottomSheetDialog = bottomSheetDialogBuilder.create()
bottomSheetDialog.show() bottomSheetDialog.show()
// bottomSheetDialog.setContentView(R.layout.sort_bottom_sheet)
val providerList = bottomSheetDialog.findViewById<ListView>(R.id.sort_providers)!! val providerList =
val subtitleList = bottomSheetDialog.findViewById<ListView>(R.id.sort_subtitles)!! bottomSheetDialog.findViewById<ListView>(R.id.sort_providers)!!
val subtitleList =
bottomSheetDialog.findViewById<ListView>(R.id.sort_subtitles)!!
if (subTracks.isEmpty()) { if (subTracks.isEmpty()) {
bottomSheetDialog.findViewById<LinearLayout>(R.id.sort_subtitles_holder)?.visibility = GONE bottomSheetDialog.findViewById<LinearLayout>(R.id.sort_subtitles_holder)?.visibility =
GONE
} else { } else {
val arrayAdapter = ArrayAdapter<String>(view.context, R.layout.sort_bottom_single_choice) val arrayAdapter =
ArrayAdapter<String>(view.context, R.layout.sort_bottom_single_choice)
arrayAdapter.add(view.context.getString(R.string.no_subtitles)) arrayAdapter.add(view.context.getString(R.string.no_subtitles))
arrayAdapter.addAll(subTracks.mapNotNull { it.name }) arrayAdapter.addAll(subTracks.mapNotNull { it.name })
@ -121,8 +144,10 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
subtitleList.adapter = arrayAdapter subtitleList.adapter = arrayAdapter
val currentTracks = remoteMediaClient?.mediaStatus?.activeTrackIds val currentTracks = remoteMediaClient?.mediaStatus?.activeTrackIds
val subtitleIndex = if (currentTracks == null) 0 else subTracks.map { it.id }
.indexOfFirst { currentTracks.contains(it) } + 1 val subtitleIndex =
if (currentTracks == null) 0 else subTracks.map { it.id }
.indexOfFirst { currentTracks.contains(it) } + 1
subtitleList.setSelection(subtitleIndex) subtitleList.setSelection(subtitleIndex)
subtitleList.setItemChecked(subtitleIndex, true) subtitleList.setItemChecked(subtitleIndex, true)
@ -134,7 +159,9 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
ChromecastSubtitlesFragment.getCurrentSavedStyle().apply { ChromecastSubtitlesFragment.getCurrentSavedStyle().apply {
val font = TextTrackStyle() val font = TextTrackStyle()
font.setFontFamily(fontFamily ?: "Google Sans") font.setFontFamily(fontFamily ?: "Google Sans")
fontGenericFamily?.let { font.fontGenericFamily = it } fontGenericFamily?.let {
font.fontGenericFamily = it
}
font.windowColor = windowColor font.windowColor = windowColor
font.backgroundColor = backgroundColor font.backgroundColor = backgroundColor
@ -151,7 +178,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
if (!it.status.isSuccess) { if (!it.status.isSuccess) {
Log.e( Log.e(
"CHROMECAST", "Failed with status code:" + "CHROMECAST", "Failed with status code:" +
it.status.statusCode + " > " + it.status.statusMessage it.status.statusCode + " > " + it.status.statusMessage
) )
} }
} }
@ -160,15 +187,17 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
} }
} }
// https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages.MediaInformation //https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages.MediaInformation
val contentUrl = (remoteMediaClient?.currentItem?.media?.contentUrl val contentUrl = (remoteMediaClient?.currentItem?.media?.contentUrl
?: remoteMediaClient?.currentItem?.media?.contentId) ?: remoteMediaClient?.currentItem?.media?.contentId)
val sortingMethods = items.map { "${it.name} ${Qualities.getStringByInt(it.quality)}" } val sortingMethods =
.toTypedArray() items.map { "${it.name} ${Qualities.getStringByInt(it.quality)}" }
.toTypedArray()
val sotringIndex = items.indexOfFirst { it.url == contentUrl } val sotringIndex = items.indexOfFirst { it.url == contentUrl }
val arrayAdapter = ArrayAdapter<String>(view.context, R.layout.sort_bottom_single_choice) val arrayAdapter =
ArrayAdapter<String>(view.context, R.layout.sort_bottom_single_choice)
arrayAdapter.addAll(sortingMethods.toMutableList()) arrayAdapter.addAll(sortingMethods.toMutableList())
providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE
@ -178,8 +207,10 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
providerList.setOnItemClickListener { _, _, which, _ -> providerList.setOnItemClickListener { _, _, which, _ ->
val epData = holder.episodes[holder.currentEpisodeIndex] val epData = holder.episodes[holder.currentEpisodeIndex]
fun loadMirror(index: Int) { fun loadMirror(index: Int) {
if (holder.currentLinks.size <= index) return if (holder.currentLinks.size <= index) return
val mediaItem = getMediaInfo( val mediaItem = getMediaInfo(
epData, epData,
holder, holder,
@ -189,35 +220,36 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
) )
val startAt = remoteMediaClient?.approximateStreamPosition ?: 0 val startAt = remoteMediaClient?.approximateStreamPosition ?: 0
//remoteMediaClient.load(mediaItem, true, startAt)
try { // THIS IS VERY IMPORTANT BECAUSE WE NEVER WANT TO AUTOLOAD THE NEXT EPISODE try { // THIS IS VERY IMPORTANT BECAUSE WE NEVER WANT TO AUTOLOAD THE NEXT EPISODE
val currentIdIndex = remoteMediaClient?.getItemIndex() val currentIdIndex = remoteMediaClient?.getItemIndex()
val nextId = remoteMediaClient?.mediaQueue?.itemIds?.get( val nextId = remoteMediaClient?.mediaQueue?.itemIds?.get(
currentIdIndex?.plus(1) ?: 0 currentIdIndex?.plus(1) ?: 0
) )
if (currentIdIndex == null && nextId != null) { if (currentIdIndex == null && nextId != null) {
awaitLinks( awaitLinks(
remoteMediaClient?.queueInsertAndPlayItem( remoteMediaClient?.queueInsertAndPlayItem(
MediaQueueItem.Builder(mediaItem).build(), MediaQueueItem.Builder(mediaItem).build(),
nextId, nextId,
startAt, startAt,
JSONObject(), JSONObject()
) )
) { loadMirror(index + 1) } ) {
loadMirror(index + 1)
}
} else { } else {
val mediaLoadOptions = val mediaLoadOptions =
MediaLoadOptions.Builder() MediaLoadOptions.Builder()
.setPlayPosition(startAt) .setPlayPosition(startAt)
.setAutoplay(true) .setAutoplay(true)
.build() .build()
awaitLinks( awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) {
remoteMediaClient?.load( loadMirror(index + 1)
mediaItem, }
mediaLoadOptions
)
) { loadMirror(index + 1) }
} }
} catch (_: Exception) { } catch (e: Exception) {
val mediaLoadOptions = val mediaLoadOptions =
MediaLoadOptions.Builder() MediaLoadOptions.Builder()
.setPlayPosition(startAt) .setPlayPosition(startAt)
@ -228,8 +260,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
} }
} }
} }
loadMirror(which) loadMirror(which)
bottomSheetDialog.dismissSafe(activity) bottomSheetDialog.dismissSafe(activity)
} }
} }
@ -239,19 +271,23 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
private fun getCurrentMetaData(): MetadataHolder? { private fun getCurrentMetaData(): MetadataHolder? {
return try { return try {
val data = remoteMediaClient?.mediaInfo?.customData?.toString() ?: return null val data = remoteMediaClient?.mediaInfo?.customData?.toString()
parseJson<MetadataHolder>(data) data?.toKotlinObject()
} catch (_: Exception) { } catch (e: Exception) {
null null
} }
} }
var isLoadingMore = false var isLoadingMore = false
override fun onMediaStatusUpdated() { override fun onMediaStatusUpdated() {
super.onMediaStatusUpdated() super.onMediaStatusUpdated()
val meta = getCurrentMetaData() val meta = getCurrentMetaData()
view.visibility = if ((meta?.currentLinks?.size ?: 0) > 1) VISIBLE else INVISIBLE
view.visibility = if ((meta?.currentLinks?.size
?: 0) > 1
) VISIBLE else INVISIBLE
try { try {
if (meta != null && meta.episodes.size > meta.currentEpisodeIndex + 1) { if (meta != null && meta.episodes.size > meta.currentEpisodeIndex + 1) {
val currentIdIndex = remoteMediaClient?.getItemIndex() ?: return val currentIdIndex = remoteMediaClient?.getItemIndex() ?: return
@ -263,13 +299,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val currentDuration = remoteMediaClient?.streamDuration val currentDuration = remoteMediaClient?.streamDuration
val currentPosition = remoteMediaClient?.approximateStreamPosition val currentPosition = remoteMediaClient?.approximateStreamPosition
if (currentDuration != null && currentPosition != null) if (currentDuration != null && currentPosition != null)
DataStoreHelper.setViewPosAndResume( DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration)
epData.id,
currentPosition,
currentDuration,
epData,
meta.episodes.getOrNull(index + 1),
)
} catch (t: Throwable) { } catch (t: Throwable) {
logError(t) logError(t)
} }
@ -279,11 +309,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
ioSafe { ioSafe {
val currentLinks = mutableSetOf<ExtractorLink>() val currentLinks = mutableSetOf<ExtractorLink>()
val currentSubs = mutableSetOf<SubtitleData>() val currentSubs = mutableSetOf<SubtitleData>()
val generator = RepoLinkGenerator(listOf(epData)) val generator = RepoLinkGenerator(listOf(epData))
val isSuccessful = safeApiCall { val isSuccessful = safeApiCall {
generator.generateLinks( generator.generateLinks(
clearCache = false, clearCache = false,
sourceTypes = LOADTYPE_CHROMECAST, allowedTypes = LOADTYPE_CHROMECAST,
callback = { callback = {
it.first?.let { link -> it.first?.let { link ->
currentLinks.add(link) currentLinks.add(link)
@ -291,9 +323,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
}, subtitleCallback = { }, subtitleCallback = {
currentSubs.add(it) currentSubs.add(it)
}, },
offset = 0, isCasting = true)
isCasting = true,
)
} }
val sortedLinks = sortUrls(currentLinks) val sortedLinks = sortUrls(currentLinks)
@ -303,18 +333,32 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val jsonCopy = meta.copy( val jsonCopy = meta.copy(
currentLinks = sortedLinks, currentLinks = sortedLinks,
currentSubtitles = sortedSubs, currentSubtitles = sortedSubs,
currentEpisodeIndex = index, currentEpisodeIndex = index
) )
val done = JSONObject(jsonCopy.toJson()) val done =
JSONObject(jsonCopy.toJson())
val mediaInfo = getMediaInfo( val mediaInfo = getMediaInfo(
epData, epData,
jsonCopy, jsonCopy,
0, 0,
done, done,
sortedSubs, sortedSubs
) )
/*fun loadIndex(index: Int) {
println("LOAD INDEX::::: $index")
if (meta.currentLinks.size <= index) return
val info = getMediaInfo(
epData,
meta,
index,
done)
awaitLinks(remoteMediaClient?.load(info, true, 0)) {
loadIndex(index + 1)
}
}*/
activity.runOnUiThread { activity.runOnUiThread {
awaitLinks( awaitLinks(
remoteMediaClient?.queueAppendItem( remoteMediaClient?.queueAppendItem(
@ -323,6 +367,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
) )
) { ) {
println("FAILED TO LOAD NEXT ITEM") println("FAILED TO LOAD NEXT ITEM")
// loadIndex(1)
} }
isLoadingMore = false isLoadingMore = false
} }
@ -345,7 +390,10 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
class SkipTimeController(val view: ImageView, forwards: Boolean) : UIController() { class SkipTimeController(val view: ImageView, forwards: Boolean) : UIController() {
init { init {
//val settingsManager = PreferenceManager.getDefaultSharedPreferences()
//val time = settingsManager?.getInt("chromecast_tap_time", 30) ?: 30
val time = 30 val time = 30
//view.setImageResource(if (forwards) R.drawable.netflix_skip_forward else R.drawable.netflix_skip_back)
view.setImageResource(if (forwards) R.drawable.go_forward_30 else R.drawable.go_back_30) view.setImageResource(if (forwards) R.drawable.go_forward_30 else R.drawable.go_back_30)
view.setOnClickListener { view.setOnClickListener {
remoteMediaClient?.let { remoteMediaClient?.let {
@ -388,4 +436,4 @@ class ControllerActivity : ExpandedControllerActivity() {
SkipNextEpisodeController(skipOpButton) SkipNextEpisodeController(skipOpButton)
) )
} }
} }

View file

@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.content.withStyledAttributes
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -155,9 +154,10 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
init { init {
if (attrs != null) { if (attrs != null) {
context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { val attrsArray = intArrayOf(android.R.attr.columnWidth)
columnWidth = getDimensionPixelSize(0, -1) val array = context.obtainStyledAttributes(attrs, attrsArray)
} columnWidth = array.getDimensionPixelSize(0, -1)
array.recycle()
} }
layoutManager = manager layoutManager = manager

View file

@ -0,0 +1,97 @@
// https://github.com/googlecodelabs/android-kotlin-animation-property-animation/tree/master/begin
package com.lagradost.cloudstream3.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.os.Bundle
import android.os.Handler
import android.view.View
import android.view.animation.AccelerateInterpolator
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isVisible
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.ActivityEasterEggMonkeBinding
class EasterEggMonke : AppCompatActivity() {
lateinit var binding : ActivityEasterEggMonkeBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityEasterEggMonkeBinding.inflate(layoutInflater)
setContentView(binding.root)
val handler = Handler(mainLooper)
lateinit var runnable: Runnable
runnable = Runnable {
shower()
handler.postDelayed(runnable, 300)
}
handler.postDelayed(runnable, 1000)
}
private fun shower() {
val containerW = binding.frame.width
val containerH = binding.frame.height
var starW: Float = binding.monke.width.toFloat()
var starH: Float = binding.monke.height.toFloat()
val newStar = AppCompatImageView(this)
val idx = (monkeys.size * Math.random()).toInt()
newStar.setImageResource(monkeys[idx])
newStar.isVisible = true
newStar.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT)
binding.frame.addView(newStar)
newStar.scaleX += Math.random().toFloat() * 1.5f
newStar.scaleY = newStar.scaleX
starW *= newStar.scaleX
starH *= newStar.scaleY
newStar.translationX = Math.random().toFloat() * containerW - starW / 2
val mover = ObjectAnimator.ofFloat(newStar, View.TRANSLATION_Y, -starH, containerH + starH)
mover.interpolator = AccelerateInterpolator(1f)
val rotator = ObjectAnimator.ofFloat(newStar, View.ROTATION,
(Math.random() * 1080).toFloat())
rotator.interpolator = LinearInterpolator()
val set = AnimatorSet()
set.playTogether(mover, rotator)
set.duration = (Math.random() * 1500 + 2500).toLong()
set.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
binding.frame.removeView(newStar)
}
})
set.start()
}
companion object {
val monkeys = listOf(
R.drawable.monke_benene,
R.drawable.monke_burrito,
R.drawable.monke_coco,
R.drawable.monke_cookie,
R.drawable.monke_flusdered,
R.drawable.monke_funny,
R.drawable.monke_like,
R.drawable.monke_party,
R.drawable.monke_sob,
R.drawable.monke_drink,
R.drawable.benene,
R.drawable.ic_launcher_foreground
)
}
}

View file

@ -1,177 +0,0 @@
package com.lagradost.cloudstream3.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.view.animation.AccelerateInterpolator
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.random.Random
class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate)
) {
// planet of monks
private val monkeys: List<Int> = listOf(
R.drawable.monke_benene,
R.drawable.monke_burrito,
R.drawable.monke_coco,
R.drawable.monke_cookie,
R.drawable.monke_flusdered,
R.drawable.monke_funny,
R.drawable.monke_like,
R.drawable.monke_party,
R.drawable.monke_sob,
R.drawable.monke_drink,
R.drawable.benene,
R.drawable.ic_launcher_foreground,
R.drawable.quick_novel_icon,
)
private val activeMonkeys = mutableListOf<ImageView>()
private var spawningJob: Job? = null
override fun fixLayout(view: View) = Unit
override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) {
activity?.hideSystemUI()
spawningJob = lifecycleScope.launch {
delay(1000)
while (isActive) {
spawnMonkey(binding)
delay(500)
}
}
}
private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) {
val newMonkey = ImageView(context ?: return).apply {
setImageResource(monkeys.random())
isVisible = true
}
val initialScale = Random.nextFloat() * 1.5f + 0.5f
newMonkey.scaleX = initialScale
newMonkey.scaleY = initialScale
newMonkey.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
val monkeyW = newMonkey.measuredWidth * initialScale
val monkeyH = newMonkey.measuredHeight * initialScale
newMonkey.x = Random.nextFloat() * (binding.frame.width.toFloat() - monkeyW)
newMonkey.y = Random.nextFloat() * (binding.frame.height.toFloat() - monkeyH)
binding.frame.addView(newMonkey, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT
))
activeMonkeys.add(newMonkey)
newMonkey.alpha = 0f
ObjectAnimator.ofFloat(newMonkey, View.ALPHA, 0f, 1f).apply {
duration = Random.nextLong(1000, 2500)
interpolator = AccelerateInterpolator()
start()
}
@SuppressLint("ClickableViewAccessibility")
newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) }
startFloatingAnimation(newMonkey, binding)
}
private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) {
val floatUpAnimator = ObjectAnimator.ofFloat(
monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat()
).apply {
duration = Random.nextLong(8000, 15000)
interpolator = LinearInterpolator()
}
floatUpAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
binding.frame.removeView(monkey)
activeMonkeys.remove(monkey)
}
})
floatUpAnimator.start()
monkey.tag = floatUpAnimator
}
private fun handleTouch(
view: View,
event: MotionEvent,
binding: FragmentEasterEggMonkeBinding
): Boolean {
val monkey = view as ImageView
when (event.action) {
MotionEvent.ACTION_DOWN -> {
(monkey.tag as? ObjectAnimator)?.pause()
return true
}
MotionEvent.ACTION_MOVE -> {
// Update both X and Y positions properly
monkey.x = event.rawX - monkey.width / 2
monkey.y = event.rawY - monkey.height / 2
// Check if monkey touches the screen edge
if (isTouchingEdge(monkey, binding)) {
removeMonkey(monkey, binding)
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (isTouchingEdge(monkey, binding)) {
removeMonkey(monkey, binding)
} else {
startFloatingAnimation(monkey, binding)
}
return true
}
}
return false
}
private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): Boolean {
return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width ||
monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height
}
private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) {
// Fade out and remove the monkey
ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply {
duration = 300
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
binding.frame.removeView(monkey)
activeMonkeys.remove(monkey)
}
})
start()
}
}
override fun onDestroyView() {
super.onDestroyView()
activity?.showSystemUI()
spawningJob?.cancel()
}
}

View file

@ -0,0 +1,42 @@
package com.lagradost.cloudstream3.ui
import android.graphics.Canvas
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
customView.layout(parent.left, 0, parent.right, customView.measuredHeight)
for (i in 0 until parent.childCount) {
val view = parent.getChildAt(i)
if (parent.getChildAdapterPosition(view) == 0) {
c.save()
val height = customView.measuredHeight
val top = view.top - height
c.translate(0f, top.toFloat())
customView.draw(c)
c.restore()
break
}
}
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
if (parent.getChildAdapterPosition(view) == 0) {
customView.measure(
View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST),
View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST)
)
outRect.set(0, customView.measuredHeight, 0, 0)
} else {
outRect.setEmpty()
}
}
}

View file

@ -7,12 +7,12 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.core.content.withStyledAttributes
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import java.lang.ref.WeakReference
class MyMiniControllerFragment : MiniControllerFragment() { class MyMiniControllerFragment : MiniControllerFragment() {
@ -25,15 +25,26 @@ class MyMiniControllerFragment : MiniControllerFragment() {
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) {
if (currentColor == 0) {
context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) {
if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) {
currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0)
}
}
}
super.onInflate(context, attributeSet, bundle) super.onInflate(context, attributeSet, bundle)
// somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks????
if (currentColor == 0) {
WeakReference(
context.obtainStyledAttributes(
attributeSet,
R.styleable.CustomCast
)
).apply {
if (get()
?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true
) {
currentColor =
get()
?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0
}
get()?.recycle()
}.clear()
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -18,6 +18,15 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab
} }
enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) {
/*
-1 -> None
0 -> Watching
1 -> Completed
2 -> OnHold
3 -> Dropped
4 -> PlanToWatch
5 -> ReWatching
*/
NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24), NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24),
WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24), WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24),
COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24), COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24),

View file

@ -1,12 +1,17 @@
package com.lagradost.cloudstream3.ui package com.lagradost.cloudstream3.ui
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.media3.common.util.UnstableApi
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
@ -14,18 +19,19 @@ import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.network.WebViewResolver
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate)
) {
override fun fixLayout(view: View) = Unit class WebviewFragment : Fragment() {
override fun onBindingCreated(binding: FragmentWebviewBinding) { var binding: FragmentWebviewBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val url = arguments?.getString(WEBVIEW_URL) ?: "".also { val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
findNavController().popBackStack() findNavController().popBackStack()
} }
binding.webView.webViewClient = object : WebViewClient() { binding?.webView?.webViewClient = object : WebViewClient() {
@OptIn(UnstableApi::class)
override fun shouldOverrideUrlLoading( override fun shouldOverrideUrlLoading(
view: WebView?, view: WebView?,
request: WebResourceRequest? request: WebResourceRequest?
@ -40,17 +46,28 @@ class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
return super.shouldOverrideUrlLoading(view, request) return super.shouldOverrideUrlLoading(view, request)
} }
} }
binding?.webView?.apply {
binding.webView.apply {
WebViewResolver.webViewUserAgent = settings.userAgentString WebViewResolver.webViewUserAgent = settings.userAgentString
addJavascriptInterface(RepoApi(activity), "RepoApi") addJavascriptInterface(RepoApi(activity), "RepoApi")
settings.javaScriptEnabled = true settings.javaScriptEnabled = true
settings.userAgentString = USER_AGENT settings.userAgentString = USER_AGENT
settings.domStorageEnabled = true settings.domStorageEnabled = true
// WebView.setWebContentsDebuggingEnabled(true)
loadUrl(url) loadUrl(url)
} }
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val localBinding = FragmentWebviewBinding.inflate(inflater, container, false)
binding = localBinding
// Inflate the layout for this fragment
return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false)
} }
companion object { companion object {
@ -67,4 +84,4 @@ class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
activity?.loadRepository(repoUrl) activity?.loadRepository(repoUrl)
} }
} }
} }

View file

@ -1,17 +1,16 @@
package com.lagradost.cloudstream3.ui.account package com.lagradost.cloudstream3.ui.account
import android.os.Build
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import coil3.transform.RoundedCornersTransformation import coil3.transform.RoundedCornersTransformation
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding
import com.lagradost.cloudstream3.databinding.AccountListItemBinding import com.lagradost.cloudstream3.databinding.AccountListItemBinding
import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -20,174 +19,137 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
class AccountAdapter( class AccountAdapter(
private val accounts: List<DataStoreHelper.Account>,
private val accountSelectCallback: (DataStoreHelper.Account) -> Unit, private val accountSelectCallback: (DataStoreHelper.Account) -> Unit,
private val accountCreateCallback: (DataStoreHelper.Account) -> Unit, private val accountCreateCallback: (DataStoreHelper.Account) -> Unit,
private val accountEditCallback: (DataStoreHelper.Account) -> Unit, private val accountEditCallback: (DataStoreHelper.Account) -> Unit,
private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit
) : NoStateAdapter<DataStoreHelper.Account>() { ) : RecyclerView.Adapter<AccountAdapter.AccountViewHolder>() {
companion object { companion object {
const val VIEW_TYPE_SELECT_ACCOUNT = 0 const val VIEW_TYPE_SELECT_ACCOUNT = 0
const val VIEW_TYPE_ADD_ACCOUNT = 1
const val VIEW_TYPE_EDIT_ACCOUNT = 2 const val VIEW_TYPE_EDIT_ACCOUNT = 2
} }
inner class AccountViewHolder(private val binding: ViewBinding) :
RecyclerView.ViewHolder(binding.root) {
override val footers: Int = 1 fun bind(account: DataStoreHelper.Account?) {
var viewType = VIEW_TYPE_SELECT_ACCOUNT when (binding) {
is AccountListItemBinding -> binding.apply {
if (account == null) return@apply
override fun customContentViewType(item: DataStoreHelper.Account): Int { val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
return viewType
}
override fun onBindContent( val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
holder: ViewHolderState<Any>,
item: DataStoreHelper.Account,
position: Int
) {
when (val binding = holder.view) {
is AccountListItemBinding -> binding.apply {
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex accountName.text = account.name
accountImage.loadImage(account.image)
lockIcon.isVisible = account.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount
accountName.text = item.name if (isTv) {
accountImage.loadImage(item.image) // For emulator but this is fine on TV also
lockIcon.isVisible = item.lockPin != null root.isFocusableInTouchMode = true
outline.isVisible = !isTv && isLastUsedAccount if (isLastUsedAccount) {
root.requestFocus()
}
if (isTv) { root.foreground = ContextCompat.getDrawable(
// For emulator but this is fine on TV also root.context,
root.isFocusableInTouchMode = true R.drawable.outline_drawable
if (isLastUsedAccount) { )
root.requestFocus() } else {
root.setOnLongClickListener {
showAccountEditDialog(
context = root.context,
account = account,
isNewAccount = false,
accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
)
true
}
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { root.setOnClickListener {
accountSelectCallback.invoke(account)
}
}
is AccountListItemEditBinding -> binding.apply {
if (account == null) return@apply
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
accountName.text = account.name
accountImage.loadImage(account.image) {
RoundedCornersTransformation(10f)
}
lockIcon.isVisible = account.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount
if (isTv) {
// For emulator but this is fine on TV also
root.isFocusableInTouchMode = true
if (isLastUsedAccount) {
root.requestFocus()
}
root.foreground = ContextCompat.getDrawable( root.foreground = ContextCompat.getDrawable(
root.context, root.context,
R.drawable.outline_drawable R.drawable.outline_drawable
) )
} }
} else {
root.setOnLongClickListener { root.setOnClickListener {
showAccountEditDialog( showAccountEditDialog(
context = root.context, context = root.context,
account = item, account = account,
isNewAccount = false, isNewAccount = false,
accountEditCallback = { account -> accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountEditCallback.invoke( accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
account
)
},
accountDeleteCallback = { account ->
accountDeleteCallback.invoke(
account
)
}
) )
true
} }
} }
root.setOnClickListener { is AccountListItemAddBinding -> binding.apply {
accountSelectCallback.invoke(item) root.setOnClickListener {
} val remainingImages =
} DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet()
is AccountListItemEditBinding -> binding.apply { val image =
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random())
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex val accountName = root.context.getString(R.string.account)
accountName.text = item.name showAccountEditDialog(
accountImage.loadImage(item.image) {
RoundedCornersTransformation(10f)
}
lockIcon.isVisible = item.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount
if (isTv) {
// For emulator but this is fine on TV also
root.isFocusableInTouchMode = true
if (isLastUsedAccount) {
root.requestFocus()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
root.foreground = ContextCompat.getDrawable(
root.context, root.context,
R.drawable.outline_drawable DataStoreHelper.Account(
keyIndex = keyIndex,
name = "$accountName $keyIndex",
customImage = null,
defaultImageIndex = image
),
isNewAccount = true,
accountEditCallback = { account -> accountCreateCallback.invoke(account) },
accountDeleteCallback = {}
) )
} }
} }
root.setOnClickListener {
showAccountEditDialog(
context = root.context,
account = item,
isNewAccount = false,
accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountDeleteCallback = { account ->
accountDeleteCallback.invoke(
account
)
}
)
}
} }
} }
} }
override fun onBindFooter(holder: ViewHolderState<Any>) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
val binding = holder.view as? AccountListItemAddBinding ?: return AccountViewHolder(
binding.apply { binding = when (viewType) {
root.setOnClickListener {
val accounts = this@AccountAdapter.immutableCurrentList
val remainingImages =
DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }
.toSet()
val image =
DataStoreHelper.profileImages.indexOf(
remainingImages.randomOrNull()
?: DataStoreHelper.profileImages.random()
)
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
val accountName = root.context.getString(R.string.account)
showAccountEditDialog(
root.context,
DataStoreHelper.Account(
keyIndex = keyIndex,
name = "$accountName $keyIndex",
customImage = null,
defaultImageIndex = image
),
isNewAccount = true,
accountEditCallback = { account -> accountCreateCallback.invoke(account) },
accountDeleteCallback = {}
)
}
}
}
override fun onCreateFooter(parent: ViewGroup): ViewHolderState<Any> {
return ViewHolderState(
AccountListItemAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
return ViewHolderState(
when (viewType) {
VIEW_TYPE_SELECT_ACCOUNT -> { VIEW_TYPE_SELECT_ACCOUNT -> {
AccountListItemBinding.inflate( AccountListItemBinding.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
@ -195,7 +157,13 @@ class AccountAdapter(
false false
) )
} }
VIEW_TYPE_ADD_ACCOUNT -> {
AccountListItemAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
}
VIEW_TYPE_EDIT_ACCOUNT -> { VIEW_TYPE_EDIT_ACCOUNT -> {
AccountListItemEditBinding.inflate( AccountListItemEditBinding.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
@ -203,9 +171,28 @@ class AccountAdapter(
false false
) )
} }
else -> throw IllegalArgumentException("Invalid view type") else -> throw IllegalArgumentException("Invalid view type")
} }
) )
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
holder.bind(accounts.getOrNull(position))
}
var viewType = 0
override fun getItemViewType(position: Int): Int {
if (viewType != 0 && position != accounts.count()) {
return viewType
}
return when (position) {
accounts.count() -> VIEW_TYPE_ADD_ACCOUNT
else -> VIEW_TYPE_SELECT_ACCOUNT
}
}
override fun getItemCount(): Int {
return accounts.count() + 1
} }
} }

View file

@ -3,12 +3,11 @@ package com.lagradost.cloudstream3.ui.account
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.content.Intent
import android.text.Editable import android.text.Editable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone import androidx.core.view.isGone
@ -17,17 +16,12 @@ import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding
import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding
import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding
import com.lagradost.cloudstream3.databinding.LockPinDialogBinding import com.lagradost.cloudstream3.databinding.LockPinDialogBinding
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
@ -37,10 +31,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideProgress
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
import com.lagradost.cloudstream3.utils.UIHelper.showProgress
object AccountHelper { object AccountHelper {
fun showAccountEditDialog( fun showAccountEditDialog(
@ -102,7 +93,6 @@ object AccountHelper {
binding.accountImage.loadImage(account.image) binding.accountImage.loadImage(account.image)
binding.accountImage.setOnClickListener { binding.accountImage.setOnClickListener {
// Roll the image forwards once // Roll the image forwards once
currentEditAccount = currentEditAccount.copy(customImage = null)
currentEditAccount = currentEditAccount =
currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size) currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size)
binding.accountImage.loadImage(currentEditAccount.image) binding.accountImage.loadImage(currentEditAccount.image)
@ -165,57 +155,6 @@ object AccountHelper {
} }
canSetPin = true canSetPin = true
binding.editProfilePhotoButton.setOnClickListener {
val bottomSheetDialog = BottomSheetDialog(context)
val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context))
bottomSheetDialog.setContentView(sheetBinding.root)
bottomSheetDialog.show()
sheetBinding.apply {
text1.text = context.getString(R.string.edit_profile_image_title)
nginxTextInput.hint = context.getString(R.string.edit_profile_image_hint)
applyBtt.setOnClickListener {
val url = sheetBinding.nginxTextInput.text.toString()
if (url.isEmpty()) {
showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT)
return@setOnClickListener
}
applyBtt.showProgress()
val imageLoader = ImageLoader(context)
val request = ImageRequest.Builder(context)
.data(url)
.allowHardware(false)
.listener(
onSuccess = { _, _ ->
currentEditAccount = currentEditAccount.copy(customImage = url)
binding.accountImage.loadImage(url)
showToast(
R.string.edit_profile_image_success,
Toast.LENGTH_SHORT
)
bottomSheetDialog.dismissSafe()
},
onError = { _, _ ->
showToast(
R.string.edit_profile_image_error_invalid,
Toast.LENGTH_SHORT
)
applyBtt.hideProgress()
},
onCancel = {
applyBtt.hideProgress()
}
)
.build()
imageLoader.enqueue(request)
}
sheetBinding.cancelBtt.setOnClickListener {
bottomSheetDialog.dismissSafe()
}
}
}
} }
fun showPinInputDialog( fun showPinInputDialog(
@ -278,7 +217,7 @@ object AccountHelper {
val activity = context.getActivity() val activity = context.getActivity()
if (activity is AccountSelectActivity) { if (activity is AccountSelectActivity) {
isPinValid = true isPinValid = true
activity.accountViewModel.handleAccountSelect(getDefaultAccount(context), activity) activity.viewModel.handleAccountSelect(getDefaultAccount(context), activity)
} }
} }
} }
@ -368,10 +307,9 @@ object AccountHelper {
builder.show() builder.show()
binding.manageAccountsButton.setOnClickListener { binding.manageAccountsButton.setOnClickListener {
activity.navigate( val accountSelectIntent = Intent(activity, AccountSelectActivity::class.java)
R.id.accountSelectActivity, accountSelectIntent.putExtra("isEditingFromMainActivity", true)
Bundle().apply { putBoolean("isEditingFromMainActivity", true) } activity.startActivity(accountSelectIntent)
)
builder.dismissSafe() builder.dismissSafe()
} }
@ -398,6 +336,7 @@ object AccountHelper {
activity.observe(viewModel.accounts) { liveAccounts -> activity.observe(viewModel.accounts) { liveAccounts ->
recyclerView.adapter = AccountAdapter( recyclerView.adapter = AccountAdapter(
liveAccounts,
accountSelectCallback = { account -> accountSelectCallback = { account ->
viewModel.handleAccountSelect(account, activity) viewModel.handleAccountSelect(account, activity)
builder.dismissSafe() builder.dismissSafe()
@ -405,9 +344,7 @@ object AccountHelper {
accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) }, accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
accountEditCallback = { viewModel.handleAccountUpdate(it, activity) }, accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) } accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
).apply { )
submitList(liveAccounts)
}
activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
// Scroll to current account (which is focused by default) // Scroll to current account (which is focused by default)

View file

@ -1,10 +1,11 @@
package com.lagradost.cloudstream3.ui.account package com.lagradost.cloudstream3.ui.account
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.fragment.app.FragmentActivity import androidx.appcompat.app.AppCompatActivity
import androidx.activity.viewModels import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity
@ -31,22 +32,19 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAut
import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.openActivity
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
class AccountSelectActivity : FragmentActivity(), BiometricCallback { class AccountSelectActivity : AppCompatActivity(), BiometricCallback {
companion object { lateinit var viewModel: AccountViewModel
var hasLoggedIn: Boolean = false
}
val accountViewModel: AccountViewModel by viewModels()
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
loadThemes(this)
@Suppress("DEPRECATION")
window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground)
// Are we editing and coming from MainActivity? // Are we editing and coming from MainActivity?
val isEditingFromMainActivity = intent.getBooleanExtra( val isEditingFromMainActivity = intent.getBooleanExtra(
@ -54,24 +52,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
false false
) )
// Sometimes we start this activity when we have already logged in
// For example when using cloudstreamsearch://
// In those cases we want to just go to the main activity instantly
if (hasLoggedIn && !isEditingFromMainActivity) {
navigateToMainActivity()
return
}
loadThemes(this)
enableEdgeToEdgeCompat()
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val skipStartup = settingsManager.getBoolean( val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
getString(R.string.skip_startup_account_select_key), false
) || accounts.count() <= 1 ) || accounts.count() <= 1
viewModel = ViewModelProvider(this)[AccountViewModel::class.java]
fun askBiometricAuth() { fun askBiometricAuth() {
if (isLayout(PHONE) && isAuthEnabled(this)) { if (isLayout(PHONE) && isAuthEnabled(this)) {
@ -89,7 +75,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
} }
} }
observe(accountViewModel.isAllowedLogin) { isAllowedLogin -> observe(viewModel.isAllowedLogin) { isAllowedLogin ->
if (isAllowedLogin) { if (isAllowedLogin) {
// We are allowed to continue to MainActivity // We are allowed to continue to MainActivity
navigateToMainActivity() navigateToMainActivity()
@ -102,15 +88,13 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex } val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex }
if (currentAccount?.lockPin != null) { if (currentAccount?.lockPin != null) {
CommonActivity.init(this) CommonActivity.init(this)
accountViewModel.handleAccountSelect(currentAccount, this, true) viewModel.handleAccountSelect(currentAccount, this, true)
} else { } else {
if (accounts.count() > 1) { if (accounts.count() > 1) {
showToast( showToast(this, getString(
this, getString( R.string.logged_account,
R.string.logged_account, currentAccount?.name
currentAccount?.name ))
)
)
} }
navigateToMainActivity() navigateToMainActivity()
@ -123,19 +107,20 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
val binding = ActivityAccountSelectBinding.inflate(layoutInflater) val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
fixSystemBarsPadding(binding.root, padTop = false)
val recyclerView: AutofitRecyclerView = binding.accountRecyclerView val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
observe(accountViewModel.accounts) { liveAccounts -> observe(viewModel.accounts) { liveAccounts ->
val adapter = AccountAdapter( val adapter = AccountAdapter(
liveAccounts,
// Handle the selected account // Handle the selected account
accountSelectCallback = { accountSelectCallback = {
accountViewModel.handleAccountSelect(it, this) viewModel.handleAccountSelect(it, this)
}, },
accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) }, accountCreateCallback = { viewModel.handleAccountUpdate(it, this) },
accountEditCallback = { accountEditCallback = {
accountViewModel.handleAccountUpdate(it, this) viewModel.handleAccountUpdate(it, this)
// We came from MainActivity, return there // We came from MainActivity, return there
// and switch to the edited account // and switch to the edited account
if (isEditingFromMainActivity) { if (isEditingFromMainActivity) {
@ -143,10 +128,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
navigateToMainActivity() navigateToMainActivity()
} }
}, },
accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } accountDeleteCallback = { viewModel.handleAccountDelete(it,this) }
).apply { )
submitList(liveAccounts)
}
recyclerView.adapter = adapter recyclerView.adapter = adapter
@ -156,13 +139,13 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
) )
} }
observe(accountViewModel.selectedKeyIndex) { selectedKeyIndex -> observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
// Scroll to current account (which is focused by default) // Scroll to current account (which is focused by default)
val layoutManager = recyclerView.layoutManager as GridLayoutManager val layoutManager = recyclerView.layoutManager as GridLayoutManager
layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0)
} }
observe(accountViewModel.isEditing) { isEditing -> observe(viewModel.isEditing) { isEditing ->
if (isEditing) { if (isEditing) {
binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24) binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24)
binding.title.setText(R.string.manage_accounts) binding.title.setText(R.string.manage_accounts)
@ -177,7 +160,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
} }
if (isEditingFromMainActivity) { if (isEditingFromMainActivity) {
accountViewModel.setIsEditing(true) viewModel.setIsEditing(true)
} }
binding.editAccountButton.setOnClickListener { binding.editAccountButton.setOnClickListener {
@ -188,7 +171,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
return@setOnClickListener return@setOnClickListener
} }
accountViewModel.toggleIsEditing() viewModel.toggleIsEditing()
} }
if (isLayout(TV or EMULATOR)) { if (isLayout(TV or EMULATOR)) {
@ -201,19 +184,17 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
askBiometricAuth() askBiometricAuth()
} }
@SuppressLint("UnsafeIntentLaunch")
private fun navigateToMainActivity() { private fun navigateToMainActivity() {
hasLoggedIn = true val mainIntent = Intent(this, MainActivity::class.java)
// We want to propagate any intent we get here to MainActivity since this is just an intermediary startActivity(mainIntent)
openActivity(MainActivity::class.java, baseIntent = intent)
finish() // Finish the account selection activity finish() // Finish the account selection activity
} }
override fun onAuthenticationSuccess() { override fun onAuthenticationSuccess() {
Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity") Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity")
} }
override fun onAuthenticationError() { override fun onAuthenticationError() {
finish() finish()
} }
} }

View file

@ -4,8 +4,8 @@ import android.content.Context
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper

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