diff --git a/.github/locales.py b/.github/locales.py
index a74d7258834..6127d9d806e 100644
--- a/.github/locales.py
+++ b/.github/locales.py
@@ -1,14 +1,13 @@
import re
import glob
import requests
-import os
import lxml.etree as ET # builtin library doesn't preserve comments
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
START_MARKER = "/* begin language list */"
END_MARKER = "/* end language list */"
-XML_NAME = "app/src/main/res/values-"
+XML_NAME = "app/src/main/res/values-b+"
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
INDENT = " "*4
@@ -21,29 +20,29 @@
# Load already added langs
languages = {}
-for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
- flag, name, iso = lang.groups()
- languages[iso] = (flag, name)
+for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest):
+ name, iso = lang.groups()
+ languages[iso] = name
# Add not yet added langs
for folder in glob.glob(f"{XML_NAME}*"):
- iso = folder[len(XML_NAME):]
+ iso = folder[len(XML_NAME):].replace("+", "-")
if iso not in languages.keys():
- entry = iso_map.get(iso.lower(),{'nativeName':iso})
- languages[iso] = ("", entry['nativeName'].split(',')[0])
+ entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found
+ languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple
-# Create triples
-triples = []
-for iso in sorted(languages.keys()):
- flag, name = languages[iso]
- triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
+# Create pairs
+pairs = []
+for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name
+ name = languages[iso]
+ pairs.append(f'{INDENT}Pair("{name}", "{iso}"),')
# Update settings file
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
before_src +
START_MARKER +
"\n" +
- "\n".join(triples) +
+ "\n".join(pairs) +
"\n" +
END_MARKER +
after_src
@@ -62,8 +61,5 @@
with open(file, 'wb') as fp:
fp.write(b'\n')
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:
print(f"[{file}] {ex}")
diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml
index f62f1ba055c..b5960d5d942 100644
--- a/.github/workflows/build_to_archive.yml
+++ b/.github/workflows/build_to_archive.yml
@@ -9,7 +9,10 @@ on:
- '**/wcokey.txt'
workflow_dispatch:
-concurrency:
+permissions:
+ contents: read
+
+concurrency:
group: "Archive-build"
cancel-in-progress: true
@@ -24,6 +27,7 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
+
- name: Generate access token (archive)
id: generate_archive_token
uses: tibdex/github-app-token@v2
@@ -31,14 +35,18 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream-archive"
- - uses: actions/checkout@v4
+
+ - uses: actions/checkout@v6
+
- name: Set up JDK 17
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
- java-version: '17'
- distribution: 'adopt'
+ distribution: temurin
+ java-version: 17
+
- name: Grant execute permission for gradlew
run: chmod +x gradlew
+
- name: Fetch keystore
id: fetch_keystore
run: |
@@ -49,25 +57,31 @@ jobs:
KEY_PWD="$(cat keystore_password.txt)"
echo "::add-mask::${KEY_PWD}"
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
- run: |
- ./gradlew assemblePrerelease
+ run: ./gradlew assemblePrereleaseRelease
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_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_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- - uses: actions/checkout@v4
+ MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
+
+ - 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"
-
+ run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
+
- name: Push archive
run: |
cd $GITHUB_WORKSPACE/archive
@@ -75,4 +89,4 @@ jobs:
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
\ No newline at end of file
+ git push --force
diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml
index 666e2ba1078..d67b8a519d7 100644
--- a/.github/workflows/generate_dokka.yml
+++ b/.github/workflows/generate_dokka.yml
@@ -1,19 +1,18 @@
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:
push:
- branches:
- # choose your default branch
- - master
- - main
+ branches: [ master ]
paths-ignore:
- '*.md'
+permissions:
+ contents: read
+
+concurrency:
+ group: "dokka"
+ cancel-in-progress: true
+
jobs:
build:
runs-on: ubuntu-latest
@@ -25,32 +24,35 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/dokka"
+
- name: Checkout
- uses: actions/checkout@master
+ uses: actions/checkout@v6
with:
path: "src"
- name: Checkout dokka
- uses: actions/checkout@master
+ uses: actions/checkout@v6
with:
repository: "recloudstream/dokka"
path: "dokka"
token: ${{ steps.generate_token.outputs.token }}
-
+
- name: Clean old builds
run: |
cd $GITHUB_WORKSPACE/dokka/
rm -rf "./app"
rm -rf "./library"
- - name: Setup JDK 17
- uses: actions/setup-java@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
with:
+ distribution: temurin
java-version: 17
- distribution: 'adopt'
- - name: Setup Android SDK
- uses: android-actions/setup-android@v3
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Generate Dokka
run: |
@@ -59,8 +61,7 @@ jobs:
./gradlew docs:dokkaGeneratePublicationHtml
- 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
run: |
diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml
index 88ab3656ce9..e354d657d50 100644
--- a/.github/workflows/issue_action.yml
+++ b/.github/workflows/issue_action.yml
@@ -4,6 +4,10 @@ on:
issues:
types: [opened]
+permissions:
+ contents: read
+ issues: write
+
jobs:
issue-moderator:
runs-on: ubuntu-latest
@@ -14,6 +18,7 @@ jobs:
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
@@ -25,9 +30,10 @@ jobs:
### 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
+ uses: actions/github-script@v9
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
@@ -37,7 +43,9 @@ jobs:
repo: context.repo.repo,
labels: ["possible duplicate"]
})
- - uses: actions/checkout@v4
+
+ - uses: actions/checkout@v6
+
- name: Automatically close issues that dont follow the issue template
uses: lucasbento/auto-close-issues@v1.0.2
with:
@@ -46,6 +54,7 @@ jobs:
@${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:
@@ -55,6 +64,7 @@ jobs:
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
@@ -66,9 +76,10 @@ jobs:
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
+ uses: actions/github-script@v9
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
@@ -78,11 +89,10 @@ jobs:
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'
-
-
diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index dd608b321e3..d9a20a04b2b 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -8,10 +8,13 @@ on:
- '*.json'
- '**/wcokey.txt'
-concurrency:
+concurrency:
group: "pre-release"
cancel-in-progress: true
+permissions:
+ contents: write
+
jobs:
build:
runs-on: ubuntu-latest
@@ -23,14 +26,18 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- - uses: actions/checkout@v4
+
+ - uses: actions/checkout@v6
+
- name: Set up JDK 17
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
- java-version: '17'
- distribution: 'adopt'
+ distribution: temurin
+ java-version: 17
+
- name: Grant execute permission for gradlew
run: chmod +x gradlew
+
- name: Fetch keystore
id: fetch_keystore
run: |
@@ -41,10 +48,14 @@ jobs:
KEY_PWD="$(cat keystore_password.txt)"
echo "::add-mask::${KEY_PWD}"
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
- run: |
- ./gradlew assemblePrerelease build androidSourcesJar
- ./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
+ run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
@@ -52,8 +63,9 @@ jobs:
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
+
- name: Create pre-release
- uses: "marvinpinto/action-automatic-releases@latest"
+ uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "pre-release"
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 7f6dd412356..675ce3b2f77 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -2,22 +2,35 @@ name: Artifact Build
on: [pull_request]
+permissions:
+ contents: read
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+
- name: Set up JDK 17
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
- java-version: '17'
- distribution: 'adopt'
+ 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: Run Gradle
- run: ./gradlew assemblePrereleaseDebug
+ run: ./gradlew assemblePrereleaseDebug lint
+
- name: Upload Artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk"
diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml
index ce140e55993..0a538d5d4da 100644
--- a/.github/workflows/update_locales.yml
+++ b/.github/workflows/update_locales.yml
@@ -1,17 +1,19 @@
name: Fix locale issues
on:
- workflow_dispatch:
push:
+ branches: [ master ]
paths:
- '**.xml'
- branches:
- - master
+ workflow_dispatch:
-concurrency:
+concurrency:
group: "locale"
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
create:
runs-on: ubuntu-latest
@@ -23,15 +25,17 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream"
- - uses: actions/checkout@v4
+
+ - uses: actions/checkout@v6
with:
token: ${{ steps.generate_token.outputs.token }}
+
- name: Install dependencies
- run: |
- pip3 install lxml
+ run: pip3 install lxml requests
+
- name: Edit files
- run: |
- python3 .github/locales.py
+ run: python3 .github/locales.py
+
- name: Commit to the repo
run: |
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
diff --git a/.gitignore b/.gitignore
index 2ac6c9695ca..5fc9f0870b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,3 @@
-*.iml
-.gradle
/local.properties
/.idea/caches
/.idea/misc.xml
@@ -11,6 +9,220 @@
.DS_Store
/build
/captures
-.externalNativeBuild
.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
+
+# NDK
+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
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index 1eb497a9359..00000000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-CloudStream
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
deleted file mode 100644
index 7643783a82f..00000000000
--- a/.idea/codeStyles/Project.xml
+++ /dev/null
@@ -1,123 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- xmlns:android
-
- ^$
-
-
-
-
-
-
-
-
- xmlns:.*
-
- ^$
-
-
- BY_NAME
-
-
-
-
-
-
- .*:id
-
- http://schemas.android.com/apk/res/android
-
-
-
-
-
-
-
-
- .*:name
-
- http://schemas.android.com/apk/res/android
-
-
-
-
-
-
-
-
- name
-
- ^$
-
-
-
-
-
-
-
-
- style
-
- ^$
-
-
-
-
-
-
-
-
- .*
-
- ^$
-
-
- BY_NAME
-
-
-
-
-
-
- .*
-
- http://schemas.android.com/apk/res/android
-
-
- ANDROID_ATTRIBUTE_ORDER
-
-
-
-
-
-
- .*
-
- .*
-
-
- BY_NAME
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index 79ee123c2b2..00000000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b589d56e9f2..00000000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/discord.xml b/.idea/discord.xml
deleted file mode 100644
index d8e95616687..00000000000
--- a/.idea/discord.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index d06216153db..00000000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
deleted file mode 100644
index 333d49373bb..00000000000
--- a/.idea/jarRepositories.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
deleted file mode 100644
index 9298202cbef..00000000000
--- a/.idea/studiobot.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddfbbc..00000000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 7282979ad72..00000000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "githubPullRequests.ignoredPullRequestBranches": [
- "master"
- ],
- "java.configuration.updateBuildConfiguration": "interactive"
-}
\ No newline at end of file
diff --git a/AI-POLICY.md b/AI-POLICY.md
new file mode 100644
index 00000000000..5409393fb18
--- /dev/null
+++ b/AI-POLICY.md
@@ -0,0 +1,11 @@
+# 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.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 64fd1f873c9..ae530192998 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,53 +1,95 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
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.tasks.KotlinJvmCompile
plugins {
- id("com.android.application")
- id("kotlin-android")
- id("org.jetbrains.dokka")
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.dokka)
}
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
-val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
-val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
-
-fun getGitCommitHash(): String {
- return try {
- val headFile = file("${project.rootDir}/.git/HEAD")
-
- // Read the commit hash from .git/HEAD
- if (headFile.exists()) {
- val headContent = headFile.readText().trim()
- if (headContent.startsWith("ref:")) {
- val refPath = headContent.substring(5) // e.g., refs/heads/main
- val commitFile = file("${project.rootDir}/.git/$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
- }.take(7) // Return the short commit hash
- } catch (_: Throwable) {
- "" // Just return an empty string if any exception occurs
+
+abstract class GenerateGitHashTask : DefaultTask() {
+
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val headFile: RegularFileProperty
+
+ @get:InputDirectory
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val headsDir: DirectoryProperty
+
+ @get:OutputDirectory
+ abstract val outputDir: DirectoryProperty
+
+ @TaskAction
+ fun generate() {
+ 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("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 {
@Suppress("UnstableApiUsage")
testOptions {
unitTests.isReturnDefaultValues = true
}
- viewBinding {
- enable = true
+ // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
+ dependenciesInfo {
+ // 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 {
- if (prereleaseStoreFile != null) {
+ // We just use SIGNING_KEY_ALIAS here since it won't change
+ // so won't kill the configuration cache.
+ if (System.getenv("SIGNING_KEY_ALIAS") != null) {
create("prerelease") {
- storeFile = file(prereleaseStoreFile)
+ val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
+ val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
+
+ storeFile = prereleaseStoreFile?.let { file(it) }
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
@@ -61,12 +103,10 @@ android {
applicationId = "com.lagradost.cloudstream3"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
- versionCode = 66
- versionName = "4.5.2"
+ versionCode = 68
+ versionName = "4.7.0"
- resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
- resValue("string", "commit_hash", getGitCommitHash())
- resValue("bool", "is_prerelease", "false")
+ manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
// Reads local.properties
val localProperties = gradleLocalProperties(rootDir, project.providers)
@@ -113,12 +153,9 @@ android {
productFlavors {
create("stable") {
dimension = "state"
- resValue("bool", "is_prerelease", "false")
}
create("prerelease") {
dimension = "state"
- resValue("bool", "is_prerelease", "true")
- buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease"
if (signingConfigs.names.contains("prerelease")) {
signingConfig = signingConfigs.getByName("prerelease")
@@ -136,13 +173,29 @@ android {
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 {
- abortOnError = false
checkReleaseBuilds = false
}
buildFeatures {
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"
@@ -159,37 +212,37 @@ dependencies {
// Android Core & Lifecycle
implementation(libs.core.ktx)
+ implementation(libs.activity.ktx)
+ implementation(libs.annotation)
implementation(libs.appcompat)
- implementation(libs.bundles.navigationKtx)
- implementation(libs.lifecycle.livedata.ktx)
- implementation(libs.lifecycle.viewmodel.ktx)
+ implementation(libs.fragment.ktx)
+ implementation(libs.bundles.lifecycle)
+ implementation(libs.bundles.navigation)
+ implementation(libs.kotlinx.collections.immutable)
// Design & UI
implementation(libs.preference.ktx)
implementation(libs.material)
implementation(libs.constraintlayout)
- implementation(libs.swiperefreshlayout)
// Coil Image Loading
- implementation(libs.coil)
- implementation(libs.coil.network.okhttp)
+ implementation(libs.bundles.coil)
// Media 3 (ExoPlayer)
implementation(libs.bundles.media3)
implementation(libs.video)
+ // FFmpeg Decoding
+ implementation(libs.bundles.nextlib)
+
+ // Anime-db for filler
+ implementation(libs.anime.db)
+
// PlayBack
implementation(libs.colorpicker) // Subtitle Color Picker
implementation(libs.newpipeextractor) // For Trailers
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
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
implementation(libs.palette.ktx) // Palette for Images -> Colors
@@ -200,50 +253,32 @@ dependencies {
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
// Extensions & Other Libs
+ implementation(libs.jsoup) // HTML Parser
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
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
- implementation(libs.conscrypt.android) {
- version {
- strictly("2.5.2")
- }
- because("2.5.3 crashes everything for everyone.")
- } // To Fix SSL Fu*kery on Android 9
- implementation(libs.jackson.module.kotlin) {
- 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
+ implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
+ implementation(libs.jackson.module.kotlin) // JSON Parser
+ implementation(libs.zipline)
// Torrent Support
implementation(libs.torrentserver)
// Downloading & Networking
- implementation(libs.work.runtime)
implementation(libs.work.runtime.ktx)
implementation(libs.nicehttp) // HTTP Lib
- 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)
- })
+ implementation(project(":library"))
}
tasks.register("androidSourcesJar") {
archiveClassifier.set("sources")
- from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
+ from(android.sourceSets.getByName("main").java.directories) // Full Sources
}
tasks.register("copyJar") {
+ dependsOn("build", ":library:jvmJar")
from(
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
"../library/build/libs"
@@ -270,15 +305,22 @@ tasks.register("makeJar") {
tasks.withType {
compilerOptions {
jvmTarget.set(javaTarget)
- freeCompilerArgs.add("-Xjvm-default=all-compatibility")
+ jvmDefault.set(JvmDefaultMode.ENABLE)
+ freeCompilerArgs.add("-Xannotation-default-target=param-property")
+ optIn.addAll(
+ "com.lagradost.cloudstream3.InternalAPI",
+ "com.lagradost.cloudstream3.Prerelease",
+ )
}
}
dokka {
moduleName = "App"
dokkaSourceSets {
- main {
+ configureEach {
+ suppress = name != "prereleaseDebug"
analysisPlatform = KotlinPlatform.JVM
+ displayName = "JVM"
documentedVisibilities(
VisibilityModifier.Public,
VisibilityModifier.Protected
diff --git a/app/lint.xml b/app/lint.xml
new file mode 100644
index 00000000000..b2f5e8f2bc3
--- /dev/null
+++ b/app/lint.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
index 0adfc1faedf..4c5cdea5bee 100644
--- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
@@ -136,14 +136,14 @@ class ExampleInstrumentedTest {
@Test
@Throws(AssertionError::class)
fun providerCorrectData() {
- val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
- Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
+ val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag }
+ Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty())
for (api in getAllProviders()) {
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 ${api.name} does not contain a valid language code",
- isoNames.contains(api.lang)
+ langTagsIETF.contains(api.lang)
)
Assert.assertTrue(
"Api ${api.name} does not contain any supported types",
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d960d910c6a..ee4c978f2be 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,8 +2,6 @@
-
-
@@ -18,12 +16,53 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tools:targetApi="${target_sdk_version}">
+ android:supportsPictureInPicture="true" />
+
+
+
+
+
+
+
+
+
+
+
+
@@ -144,7 +200,14 @@
+
+
+
+
+
+
+
@@ -168,7 +231,7 @@
-
+
@@ -181,21 +244,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-#include
-#include
-
-#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;
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
index 9f493fbbc75..bbe7d97debc 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
@@ -1,233 +1,78 @@
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.widget.Toast
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentActivity
-import coil3.PlatformContext
-import coil3.SingletonImageLoader
-import com.lagradost.api.setContext
-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.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
-
-class CustomReportSender : ReportSender {
- // Sends all your crashes to google forms
- override fun send(context: Context, errorContent: CrashReportData) {
- /*println("Sending report")
- val url =
- "https://docs.google.com/forms/d/e/$id/formResponse"
- val data = mapOf(
- "entry.$entry" to errorContent.toJSON()
- )
-
- thread { // to not run it on main thread
- runBlocking {
- safeAsync {
- app.post(url, data = data)
- //println("Report response: $post")
- }
- }
- }
-
- runOnMainThread { // to run it on main looper
- safe {
- Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
- }
- }*/
- }
-}
-
-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)
- }
-
+/**
+ * Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
+ * Use CloudStreamApp instead.
+ */
+@Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
+ level = DeprecationLevel.WARNING
+)
+class AcraApplication {
+ companion object {
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
+ level = DeprecationLevel.WARNING
+ )
+ val context get() = CloudStreamApp.context
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun removeKeys(folder: String): Int? =
+ CloudStreamApp.removeKeys(folder)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun 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.WARNING
+ )
+ fun 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.WARNING
+ )
+ inline fun 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.WARNING
+ )
+ inline fun 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.WARNING
+ )
+ inline fun 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.WARNING
+ )
+ inline fun getKey(folder: String, path: String, defVal: T?): T? =
+ CloudStreamApp.getKey(folder, path, defVal)
+ }
}
-
-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? = null
- var context
- get() = _context?.get()
- private set(value) {
- _context = WeakReference(value)
- setContext(WeakReference(value))
- }
-
- fun getKeyClass(path: String, valueType: Class): T? {
- return context?.getKey(path, valueType)
- }
-
- fun setKeyClass(path: String, value: T) {
- context?.setKey(path, value)
- }
-
- fun removeKeys(folder: String): Int? {
- return context?.removeKeys(folder)
- }
-
- fun setKey(path: String, value: T) {
- context?.setKey(path, value)
- }
-
- fun setKey(folder: String, path: String, value: T) {
- context?.setKey(folder, path, value)
- }
-
- inline fun getKey(path: String, defVal: T?): T? {
- return context?.getKey(path, defVal)
- }
-
- inline fun getKey(path: String): T? {
- return context?.getKey(path)
- }
-
- inline fun getKey(folder: String, path: String): T? {
- return context?.getKey(folder, path)
- }
-
- inline fun getKey(folder: String, path: String, defVal: T?): T? {
- return context?.getKey(folder, path, defVal)
- }
-
- fun getKeys(folder: String): List? {
- 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()
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt
new file mode 100644
index 00000000000..a9cd9c01edd
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt
@@ -0,0 +1,181 @@
+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? = null
+ var context
+ get() = _context?.get()
+ private set(value) {
+ _context = WeakReference(value)
+ setContext(WeakReference(value))
+ }
+
+ fun getKeyClass(path: String, valueType: Class): T? {
+ return context?.getKey(path, valueType)
+ }
+
+ fun setKeyClass(path: String, value: T) {
+ context?.setKey(path, value)
+ }
+
+ fun removeKeys(folder: String): Int? {
+ return context?.removeKeys(folder)
+ }
+
+ fun setKey(path: String, value: T) {
+ context?.setKey(path, value)
+ }
+
+ fun setKey(folder: String, path: String, value: T) {
+ context?.setKey(folder, path, value)
+ }
+
+ inline fun getKey(path: String, defVal: T?): T? {
+ return context?.getKey(path, defVal)
+ }
+
+ inline fun getKey(path: String): T? {
+ return context?.getKey(path)
+ }
+
+ inline fun getKey(folder: String, path: String): T? {
+ return context?.getKey(folder, path)
+ }
+
+ inline fun getKey(folder: String, path: String, defVal: T?): T? {
+ return context?.getKey(folder, path, defVal)
+ }
+
+ fun getKeys(folder: String): List? {
+ 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()
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
index 9a8e274f5ab..ed0aaf9b761 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
@@ -1,13 +1,16 @@
package com.lagradost.cloudstream3
-import android.Manifest
+import android.annotation.SuppressLint
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.content.res.Resources
+import android.Manifest
import android.os.Build
+import android.os.Handler
+import android.os.Looper
import android.util.DisplayMetrics
import android.util.Log
import android.view.Gravity
@@ -24,34 +27,41 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.children
+import androidx.core.view.isNotEmpty
import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession
import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.ui.player.PlayerEventType
+import com.lagradost.cloudstream3.syncproviders.AccountManager
+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.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.result.ActorAdaptor
+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.utils.UiText
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
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.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Event
-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.showInputMethod
import com.lagradost.cloudstream3.utils.UIHelper.toPx
-import org.schabi.newpipe.extractor.NewPipe
+import com.lagradost.cloudstream3.utils.UiText
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
+import org.schabi.newpipe.extractor.NewPipe
enum class FocusDirection {
Start,
@@ -91,17 +101,24 @@ object CommonActivity {
get() {
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
+ val screenWidthWithOrientation: Int
+ get() {
+ return displayMetrics.widthPixels
+ }
+ val screenHeightWithOrientation: Int
+ get() {
+ return displayMetrics.heightPixels
+ }
-
- var canEnterPipMode: Boolean = false
- var canShowPipMode: Boolean = false
+ var isPipDesired: Boolean = false
var isInPIPMode: Boolean = false
val onColorSelectedEvent = Event>()
val onDialogDismissedEvent = Event()
- var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair) -> Boolean)? = null
+ var appliedTheme: Int = 0
+ var appliedColor: Int = 0
private var currentToast: Toast? = null
@@ -174,23 +191,35 @@ object CommonActivity {
currentToast = toast
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) {
logError(e)
}
}
/**
- * Not all languages can be fetched from locale with a code.
- * This map allows sidestepping the default Locale(languageCode)
- * when setting the app language.
- **/
- val appLanguageExceptions = hashMapOf(
- "zh-rTW" to Locale.TRADITIONAL_CHINESE
- )
-
- fun setLocale(context: Context?, languageCode: String?) {
- if (context == null || languageCode == null) return
- val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
+ * Set locale
+ * @param languageTag shall a IETF BCP 47 conformant tag.
+ * Check [com.lagradost.cloudstream3.utils.SubtitleHelper].
+ *
+ * See locales on:
+ * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json
+ * 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?, languageTag: String?) {
+ if (context == null || languageTag == null) return
+ val locale = Locale.forLanguageTag(languageTag)
val resources: Resources = context.resources
val config = resources.configuration
Locale.setDefault(locale)
@@ -198,6 +227,7 @@ object CommonActivity {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
context.createConfigurationContext(config)
+
@Suppress("DEPRECATION")
resources.updateConfiguration(
config,
@@ -214,18 +244,11 @@ object CommonActivity {
fun init(act: Activity) {
setActivityInstance(act)
ioSafe { Torrent.deleteAllFiles() }
-
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.updateTv()
+ AccountManager.initMainAPI()
NewPipe.init(DownloaderTestImpl.getInstance())
MainActivity.activityResultLauncher =
@@ -238,7 +261,7 @@ object CommonActivity {
?: return@registerForActivityResult
action.onResultSafe(act, result.data)
removeKey("last_click_action")
- removeKey("last_opened_id")
+ removeKey("last_opened")
}
}
@@ -260,13 +283,15 @@ object CommonActivity {
}
}
+ /** Enters pip mode if it is both possible and desired to do so*/
private fun Activity.enterPIPMode() {
- if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
+ if (!isPipDesired || !this.isPIPPossible()) return
+
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
- } catch (e: Exception) {
+ } catch (_: Exception) {
// Use fallback just in case
@Suppress("DEPRECATION")
enterPictureInPictureMode()
@@ -282,10 +307,10 @@ object CommonActivity {
}
}
- fun onUserLeaveHint(act: Activity?) {
- if (canEnterPipMode && canShowPipMode) {
- act?.enterPIPMode()
- }
+ fun onUserLeaveHint(act: Activity) {
+ // On Android 12 and later we use setAutoEnterEnabled() instead.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return
+ act.enterPIPMode()
}
fun updateTheme(act: Activity) {
@@ -325,6 +350,10 @@ object CommonActivity {
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.MonetMode else R.style.AppTheme
+ "Dracula" -> R.style.DraculaMode
+ "Lavender" -> R.style.LavenderMode
+ "SilentBlue" -> R.style.SilentBlueMode
+
else -> R.style.AppTheme
}
@@ -360,6 +389,8 @@ object CommonActivity {
act.theme.applyStyle(currentTheme, 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(
@@ -392,8 +423,7 @@ object CommonActivity {
private fun View.hasContent(): Boolean {
return isShown && when (this) {
- //is RecyclerView -> this.childCount > 0
- is ViewGroup -> this.childCount > 0
+ is ViewGroup -> this.isNotEmpty()
else -> true
}
}
@@ -423,7 +453,7 @@ object CommonActivity {
// 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
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
- parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
+ parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty()
} ?: false
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
@@ -502,87 +532,7 @@ object CommonActivity {
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
-
- // 149 keycode_numpad 5
- val playerEvent = 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 -> return null
- }
- val listener = playerEventListener
- if (listener != null) {
- listener.invoke(playerEvent)
- return true
- }
return null
-
- //when (keyCode) {
- // KeyEvent.KEYCODE_DPAD_CENTER -> {
- // println("DPAD PRESSED")
- // }
- //}
}
/** overrides focus and custom key events */
@@ -619,6 +569,7 @@ object CommonActivity {
else -> null
}
+
// println("NEXT FOCUS : $nextView")
if (nextView != null) {
nextView.requestFocus()
@@ -626,10 +577,13 @@ object CommonActivity {
return true
}
+ // TODO: Figure out why removing the check for SearchAutoComplete seems
+ // to break focus on TV as it shouldn't need to be used.
+ @SuppressLint("RestrictedApi")
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) {
- UIHelper.showInputMethod(act.currentFocus?.findFocus())
+ showInputMethod(act.currentFocus?.findFocus())
}
//println("Keycode: $keyCode")
@@ -638,7 +592,6 @@ object CommonActivity {
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
-
}
// if someone else want to override the focus then don't handle the event as it is already
diff --git a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt
deleted file mode 100644
index 045a7963ad0..00000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-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))
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index 7f5c499ffdc..8a98bd2972e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -9,7 +9,6 @@ import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Rect
-import android.net.Uri
import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
@@ -24,15 +23,16 @@ import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Toast
-import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IdRes
import androidx.annotation.MainThread
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView
-import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.edit
+import androidx.core.net.toUri
import androidx.core.view.children
+import androidx.core.view.get
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
@@ -50,6 +50,7 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSnapHelper
import androidx.recyclerview.widget.RecyclerView
+import androidx.viewpager2.widget.ViewPager2
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.Session
import com.google.android.gms.cast.framework.SessionManager
@@ -63,9 +64,9 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.initAll
-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.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.loadThemes
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
@@ -90,14 +91,13 @@ import com.lagradost.cloudstream3.plugins.PluginManager.___DO_NOT_CALL_FROM_A_PL
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
+import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.APIRepository
@@ -119,6 +119,7 @@ import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
@@ -156,17 +157,20 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
-import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
+import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
+import com.lagradost.cloudstream3.utils.TvChannelUtils
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
-import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
+import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
+import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
+import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
@@ -184,7 +188,9 @@ import java.nio.charset.Charset
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.system.exitProcess
-
+import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
companion object {
@@ -194,6 +200,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
const val ANIMATED_OUTLINE: Boolean = false
var lastError: String? = null
+ /** Update lastError variable based on error file, to check if app crashed.
+ * Can be called multiple times without changing the lastError variable changing.
+ **/
+ fun setLastError(context: Context) {
+ if (lastError != null) return
+
+ val errorFile = context.filesDir.resolve("last_error")
+ if (errorFile.exists() && errorFile.isFile) {
+ lastError = errorFile.readText(Charset.defaultCharset())
+ errorFile.delete()
+ } else {
+ lastError = null
+ }
+ }
+
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
@@ -255,7 +276,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* @return true if the str has launched an app task (be it successful or not)
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
* */
- @Suppress("DEPRECATION_ERROR")
fun handleAppIntentUrl(
activity: FragmentActivity?,
str: String?,
@@ -275,28 +295,29 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
loadRepository(realUrl)
return true
} else if (str.contains(APP_STRING)) {
- for (api in OAuth2Apis) {
- if (str.contains("/${api.redirectUrl}")) {
+ for (api in AccountManager.allApis) {
+ if (api.isValidRedirectUrl(str)) {
ioSafe {
Log.i(TAG, "handleAppIntent $str")
- val isSuccessful = api.handleRedirect(str)
-
- if (isSuccessful) {
- Log.i(TAG, "authenticated ${api.name}")
- } else {
- Log.i(TAG, "failed to authenticate ${api.name}")
- }
-
- this@with.runOnUiThread {
- try {
- showToast(
- getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format(
- api.name
- )
- )
- } catch (e: Exception) {
- logError(e) // format might fail
+ try {
+ val isSuccessful = api.login(str)
+ if (isSuccessful) {
+ Log.i(TAG, "authenticated ${api.name}")
+ } else {
+ Log.i(TAG, "failed to authenticate ${api.name}")
}
+ showToast(
+ if (isSuccessful) {
+ txt(R.string.authenticated_user, api.name)
+ } else {
+ txt(R.string.authenticated_user_fail, api.name)
+ }
+ )
+ } catch (t: Throwable) {
+ logError(t)
+ showToast(
+ txt(R.string.authenticated_user_fail, api.name)
+ )
}
}
return true
@@ -331,7 +352,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
activity?.findViewById(R.id.nav_rail_view)?.selectedItemId =
R.id.navigation_search
} else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
- val uri = Uri.parse(str)
+ val uri = str.toUri()
val name = uri.getQueryParameter("name")
val url = URLDecoder.decode(uri.authority, "UTF-8")
@@ -341,7 +362,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
LinkGenerator(
listOf(BasicLink(url, name)),
extract = true,
- )
+ id = url.hashCode()
+ ), 0
)
)
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
@@ -357,6 +379,20 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
START_ACTION_RESUME_LATEST
)
}
+ } else if (str.startsWith(APP_STRING_SHARE)) {
+ try {
+ val data = str.substringAfter("$APP_STRING_SHARE:")
+ val parts = data.split("?", limit = 2)
+ loadResult(
+ String(base64DecodeArray(parts[1]), Charsets.UTF_8),
+ String(base64DecodeArray(parts[0]), Charsets.UTF_8),
+ ""
+ )
+ return true
+ } catch (e: Exception) {
+ showToast("Invalid Uri", Toast.LENGTH_SHORT)
+ return false
+ }
} else if (!isWebview) {
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
this.navigate(R.id.navigation_downloads)
@@ -385,9 +421,29 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
return false
}
+
+
+ fun centerView(view: View?) {
+ if (view == null) return
+ try {
+ Log.v(TAG, "centerView: $view")
+ val r = Rect(0, 0, 0, 0)
+ view.getDrawingRect(r)
+ val x = r.centerX()
+ val y = r.centerY()
+ val dx = r.width() / 2 //screenWidth / 2
+ val dy = screenHeight / 2
+ val r2 = Rect(x - dx, y - dy, x + dx, y + dy)
+ view.requestRectangleOnScreen(r2, false)
+ // TvFocus.current =TvFocus.current.copy(y=y.toFloat())
+ } catch (_: Throwable) {
+ }
+ }
}
+
var lastPopup: SearchResponse? = null
+ var lastPopupJob: Job? = null
fun loadPopup(result: SearchResponse, load: Boolean = true) {
lastPopup = result
val syncName = syncViewModel.syncName(result.apiName)
@@ -403,7 +459,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
syncViewModel.clear()
}
- if (load) {
+ lastPopupJob?.cancel()
+ lastPopupJob = if (load) {
viewModel.load(
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
.contains(DubStatus.Dubbed)
@@ -450,6 +507,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
R.id.navigation_downloads,
R.id.navigation_settings,
R.id.navigation_download_child,
+ R.id.navigation_download_queue,
R.id.navigation_subtitles,
R.id.navigation_chrome_subtitles,
R.id.navigation_settings_player,
@@ -464,7 +522,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
).contains(destination.id)
- val dontPush = listOf(
+ /*val dontPush = listOf(
R.id.navigation_home,
R.id.navigation_search,
R.id.navigation_results_phone,
@@ -495,25 +553,19 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
layoutParams = params
- }
-
- val landscape = when (resources.configuration.orientation) {
- Configuration.ORIENTATION_LANDSCAPE -> {
- true
- }
-
- Configuration.ORIENTATION_PORTRAIT -> {
- isLayout(TV or EMULATOR)
- }
-
- else -> {
- false
- }
- }
+ }*/
binding?.apply {
- navRailView.isVisible = isNavVisible && landscape
- navView.isVisible = isNavVisible && !landscape
+ navRailView.isVisible = isNavVisible && isLandscape()
+ navView.isVisible = isNavVisible && !isLandscape()
+ navHostFragment.apply {
+ val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
+ layoutParams =
+ (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
+ marginStart =
+ if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
+ }
+ }
/**
* We need to make sure if we return to a sub-fragment,
@@ -521,7 +573,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* highlight the wrong one in UI.
*/
when (destination.id) {
- in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
+ in listOf(
+ R.id.navigation_downloads,
+ R.id.navigation_download_child,
+ R.id.navigation_download_queue
+ ) -> {
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
}
@@ -643,7 +699,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
.setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ }
.setPositiveButton(R.string.yes) { _, _ ->
if (dontShowAgainCheck.isChecked) {
- settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit()
+ settingsManager.edit(commit = true) {
+ putInt(getString(R.string.confirm_exit_key), 1)
+ }
}
// finish() causes a bug on some TVs where player
// may keep playing after closing the app.
@@ -668,10 +726,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
this.sendBroadcast(broadcastIntent)
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
+ detachBackPressedCallback("MainActivityDefault")
super.onDestroy()
}
- override fun onNewIntent(intent: Intent?) {
+ override fun onNewIntent(intent: Intent) {
handleAppIntent(intent)
super.onNewIntent(intent)
}
@@ -680,6 +739,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (intent == null) return
val str = intent.dataString
loadCache()
+
handleAppIntentUrl(this, str, false, intent.extras)
}
@@ -698,6 +758,36 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
// Check if we are already at the selected destination
if (navController.currentDestination?.id == destinationId) return false
+ // Make all nav buttons focus on this specific view when nextFocusRightId
+ val targetView = when (destinationId) {
+ // Please note that if R.id.navigation_home is readded, then it will only take affect when
+ // navigation to home for the second time as onNavDestinationSelected will not get called
+ // when first loading up the app
+
+ // R.id.navigation_home -> R.id.home_preview_change_api
+ R.id.navigation_search -> R.id.main_search
+ R.id.navigation_library -> R.id.main_search
+ R.id.navigation_downloads -> R.id.download_appbar
+ else -> null
+ }
+ if (targetView != null && isLayout(TV or EMULATOR)) {
+ val fromView = binding?.navRailView
+ if (fromView != null) {
+ fromView.nextFocusRightId = targetView
+
+ for (focusView in arrayOf(
+ R.id.navigation_downloads,
+ R.id.navigation_home,
+ R.id.navigation_search,
+ R.id.navigation_library,
+ R.id.navigation_settings,
+ )) {
+ fromView.findViewById(focusView)?.nextFocusRightId = targetView
+ }
+ }
+ }
+
+
val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true)
.setEnterAnim(R.anim.enter_anim)
.setExitAnim(R.anim.exit_anim)
@@ -719,6 +809,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
}
+
private val pluginsLock = Mutex()
private fun onAllPluginsLoaded(success: Boolean = false) {
ioSafe {
@@ -769,6 +860,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
private fun hidePreviewPopupDialog() {
bottomPreviewPopup.dismissSafe(this)
+ lastPopupJob?.cancel()
+ lastPopupJob = null
bottomPreviewPopup = null
bottomPreviewBinding = null
}
@@ -1088,35 +1181,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
}
- private fun centerView(view: View?) {
- if (view == null) return
- try {
- Log.v(TAG, "centerView: $view")
- val r = Rect(0, 0, 0, 0)
- view.getDrawingRect(r)
- val x = r.centerX()
- val y = r.centerY()
- val dx = r.width() / 2 //screenWidth / 2
- val dy = screenHeight / 2
- val r2 = Rect(x - dx, y - dy, x + dx, y + dy)
- view.requestRectangleOnScreen(r2, false)
- // TvFocus.current =TvFocus.current.copy(y=y.toFloat())
- } catch (_: Throwable) {
- }
- }
-
- @Suppress("DEPRECATION_ERROR")
override fun onCreate(savedInstanceState: Bundle?) {
- app.initClient(this)
+ app.initClient(this, ignoreSSL = false)
+ @OptIn(UnsafeSSL::class)
+ insecureApp.initClient(this, ignoreSSL = true)
+
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val errorFile = filesDir.resolve("last_error")
- if (errorFile.exists() && errorFile.isFile) {
- lastError = errorFile.readText(Charset.defaultCharset())
- errorFile.delete()
- } else {
- lastError = null
- }
+ setLastError(this)
val settingsForProvider = SettingsJson()
settingsForProvider.enableAdult =
@@ -1125,6 +1197,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
MainAPI.settingsForProvider = settingsForProvider
loadThemes(this)
+ enableEdgeToEdgeCompat()
+ setNavigationBarColorCompat(R.attr.primaryGrayBackground)
updateLocale()
super.onCreate(savedInstanceState)
try {
@@ -1145,6 +1219,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
if (appVer != lastAppAutoBackup) {
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
+ if (lastAppAutoBackup.isEmpty()) return@safe
+
safe {
backup(this)
}
@@ -1176,7 +1252,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (isLayout(TV)) {
// Put here any button you don't want focusing it to center the view
val exceptionButtons = listOf(
- R.id.home_preview_play_btt,
+ //R.id.home_preview_play_btt,
R.id.home_preview_info_btt,
R.id.home_preview_hidden_next_focus,
R.id.home_preview_hidden_prev_focus,
@@ -1208,6 +1284,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
null
}
+ binding?.apply {
+ fixSystemBarsPadding(
+ navView,
+ heightResId = R.dimen.nav_view_height,
+ padTop = false,
+ overlayCutout = false
+ )
+
+ fixSystemBarsPadding(
+ navRailView,
+ widthResId = R.dimen.nav_rail_view_width,
+ padRight = false,
+ padTop = false
+ )
+ }
+
// overscan
val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx
binding?.homeRoot?.setPadding(padding, padding, padding, padding)
@@ -1298,6 +1390,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
false
)
}
+
+// Add your channel creation here
+
}
} else {
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
@@ -1535,18 +1630,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
// init accounts
ioSafe {
- for (api in accountManagers) {
- api.init()
- }
-
- inAppAuths.amap { api ->
- try {
- api.initialize()
- } catch (e: Exception) {
- logError(e)
- }
- }
-
// we need to run this after we init all apis, otherwise currentSyncApi will fuck itself
this@MainActivity.runOnUiThread {
// Change library icon with logo of current api in sync
@@ -1599,10 +1682,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (navDestination.matchDestination(R.id.navigation_home)) {
attachBackPressedCallback("MainActivity") {
showConfirmExitDialog(settingsManager)
- @Suppress("DEPRECATION")
- window?.navigationBarColor =
- colorFromAttribute(R.attr.primaryGrayBackground)
- updateLocale()
}
} else detachBackPressedCallback("MainActivity")
}
@@ -1630,17 +1709,27 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
navController
)
}
+
}
binding?.navRailView?.apply {
- itemRippleColor = rippleColor
- itemActiveIndicatorColor = rippleColor
+ if (isLayout(PHONE)) {
+ itemRippleColor = rippleColor
+ itemActiveIndicatorColor = rippleColor
+ } else {
+ val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f))
+ val rippleColorTransparent =
+ ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f))
+ itemSpacing = 12.toPx // expandedItemSpacing does not have an attr
+ itemRippleColor = rippleColorTransparent
+ itemActiveIndicatorColor = rippleColor
+ }
setupWithNavController(navController)
- if (isLayout(TV or EMULATOR)) {
+ /*if (isLayout(TV or EMULATOR)) {
background?.alpha = 200
} else {
background?.alpha = 255
- }
+ }*/
setOnItemSelectedListener { item ->
onNavDestinationSelected(
@@ -1649,6 +1738,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
)
}
+
fun noFocus(view: View) {
view.tag = view.context.getString(R.string.tv_no_focus_tag)
(view as? ViewGroup)?.let {
@@ -1687,6 +1777,104 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
}
+ val rail = binding?.navRailView
+ if (rail != null) {
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_UNLABELED
+ //val focus = mutableSetOf()
+
+ var prevId: Int? = null
+ var prevView: View? = null
+
+ // The genius engineers at google did not actually
+ // write a nextFocus for the navrail
+ rail.findViewById(R.id.navigation_settings)?.nextFocusDownId =
+ R.id.nav_footer_profile_card
+ for (id in arrayOf(
+ R.id.navigation_home,
+ R.id.navigation_search,
+ R.id.navigation_library,
+ R.id.navigation_downloads,
+ R.id.navigation_settings
+ )) {
+ val view = rail.findViewById(id) ?: continue
+ prevId?.let { view.nextFocusUpId = it }
+ prevView?.nextFocusDownId = id
+
+ prevView = view
+ prevId = id
+ // Uncomment for focus expand
+ /*if (!isLayout(TV)) {
+ view.onFocusChangeListener = null
+ } else {
+ view.onFocusChangeListener =
+ View.OnFocusChangeListener { v, hasFocus ->
+ if (hasFocus) {
+ focus += id
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_LABELED
+ binding?.navRailView?.expand()
+ } else {
+ focus -= id
+ v.post {
+ if (focus.isEmpty()) {
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_UNLABELED
+ binding?.navRailView?.collapse()
+ }
+ }
+ }
+ }
+ }*/
+ }
+ }
+
+ // Navigation button long click functionality to scroll to top
+ for (view in listOf(binding?.navView, binding?.navRailView)) {
+ view?.findViewById(R.id.navigation_home)?.setOnLongClickListener {
+ val recycler = binding?.root?.findViewById(R.id.home_master_recycler)
+ recycler?.smoothScrollToPosition(0)
+ return@setOnLongClickListener recycler != null
+ }
+
+ view?.findViewById(R.id.navigation_library)?.setOnLongClickListener {
+ val viewPager = binding?.root?.findViewById(R.id.viewpager)
+ ?: return@setOnLongClickListener false
+ try {
+ val children = (viewPager[0] as? RecyclerView)?.children
+ ?: return@setOnLongClickListener false
+ for (child in children) {
+ child.findViewById(R.id.page_recyclerview)
+ ?.smoothScrollToPosition(0)
+ }
+ } catch (_: IndexOutOfBoundsException) {
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ return@setOnLongClickListener true
+ }
+
+ view?.findViewById(R.id.navigation_search)?.setOnLongClickListener {
+ for (recyclerId in arrayOf(
+ R.id.search_master_recycler,
+ R.id.search_autofit_results,
+ R.id.search_history_recycler
+ )) {
+ val recycler = binding?.root?.findViewById(recyclerId)
+ ?: return@setOnLongClickListener false
+ recycler.smoothScrollToPosition(0)
+ }
+ return@setOnLongClickListener true
+ }
+
+ view?.findViewById(R.id.navigation_downloads)?.setOnLongClickListener {
+ val recycler: RecyclerView? = binding?.root?.findViewById(R.id.download_list)
+ ?: binding?.root?.findViewById(R.id.download_child_list)
+ recycler?.smoothScrollToPosition(0)
+ return@setOnLongClickListener recycler != null
+ }
+ }
+
loadCache()
updateHasTrailers()
/*nav_view.setOnNavigationItemSelectedListener { item ->
@@ -1753,7 +1941,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
fun buildMediaQueueItem(video: String): MediaQueueItem {
// val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO)
//movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream")
- val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString())
+ val mediaInfo = MediaInfo.Builder(video.toUri().toString())
.setStreamType(MediaInfo.STREAM_TYPE_NONE)
.setContentType(MimeTypes.IMAGE_JPEG)
// .setMetadata(movieMetadata).build()
@@ -1815,6 +2003,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
migrateResumeWatching()
}
+ main {
+ val channelId =
+ TvChannelUtils.getChannelId(this@MainActivity, getString(R.string.app_name))
+ if (channelId == null) {
+ Log.d("TvChannel", "Channel not found, creating")
+ TvChannelUtils.createTvChannel(this@MainActivity)
+ } else {
+ Log.d("TvChannel", "Channel ID: $channelId")
+ }
+ }
+
getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage ->
DataStoreHelper.currentHomePage = homepage
removeKey(USER_SELECTED_HOMEPAGE_API)
@@ -1846,23 +2045,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
// }
// }
- onBackPressedDispatcher.addCallback(
- this,
- object : OnBackPressedCallback(true) {
- override fun handleOnBackPressed() {
- @Suppress("DEPRECATION")
- window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground)
- updateLocale()
-
- // If we don't disable we end up in a loop with default behavior calling
- // this callback as well, so we disable it, run default behavior,
- // then re-enable this callback so it can be used for next back press.
- isEnabled = false
- onBackPressedDispatcher.onBackPressed()
- isEnabled = true
- }
- }
- )
+ attachBackPressedCallback("MainActivityDefault") {
+ setNavigationBarColorCompat(R.attr.primaryGrayBackground)
+ updateLocale()
+ runDefault()
+ }
+
+ // Start the download queue
+ DownloadQueueManager.init(this)
}
/** Biometric stuff **/
@@ -1885,4 +2075,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
false
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt
index cc64a6d394d..ac912cbeb41 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt
@@ -6,8 +6,8 @@ import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import androidx.core.net.toUri
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
@@ -21,7 +21,8 @@ import java.io.File
fun updateDurationAndPosition(position: Long, duration: Long) {
if (position <= 0 || duration <= 0) return
- DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration)
+ val episode = getKey("last_opened") ?: return
+ DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
ResultFragment.updateUI()
}
@@ -98,7 +99,7 @@ abstract class OpenInAppAction(
intent.component = ComponentName(packageName, intentClass)
}
putExtra(context, intent, video, result, index)
- setKey("last_opened_id", video.id)
+ setKey("last_opened", video)
launchResult(intent)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt
index 8407fa7a42a..4843b7617a2 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt
@@ -16,12 +16,14 @@ import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage
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.MpvKtPreviewPackage
import com.lagradost.cloudstream3.actions.temp.MpvPackage
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
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.VlcNightlyPackage
import com.lagradost.cloudstream3.actions.temp.VlcPackage
@@ -46,9 +48,11 @@ object VideoClickActionHolder {
PlayInBrowserAction(),
CopyClipboardAction(),
ViewM3U8Action(),
+ PlayMirrorAction(),
// main support external apps
VlcPackage(),
MpvPackage(),
+ MpvExPackage(),
NextPlayerPackage(),
JustPlayerPackage(),
FcastAction(),
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt
index d7f69db2c88..d414b611783 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt
@@ -5,8 +5,8 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.ui.player.ExtractorUri
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
@@ -18,8 +18,10 @@ 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.Qualities
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.txt
/**
@@ -122,7 +124,9 @@ class CloudStreamPackage : OpenInAppAction(
originalName = name ?: "Unknown",
headers = headers,
origin = SubtitleOrigin.URL,
- languageCode = null,
+ languageCode = fromCodeToLangTagIETF(name) ?:
+ fromLanguageToTagIETF(name, true) ?:
+ name,
)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt
index 102f0ac8bb3..faae3921240 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt
@@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
-import android.net.Uri
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
@@ -45,7 +44,7 @@ open class MpvKtPackage(
intent.apply {
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
- setDataAndType(Uri.parse(link.url), "video/*")
+ setDataAndType(link.url.toUri(), "video/*")
// m3u8 plays, but changing sources feature is not available
// makeTempM3U8Intent(activity, this, result)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt
index 68e619c92c8..cd49eb994e0 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt
@@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
-import android.net.Uri
import androidx.core.net.toUri
import com.lagradost.api.Log
import com.lagradost.cloudstream3.actions.OpenInAppAction
@@ -18,6 +17,9 @@ 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://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") {
override val sourceTypes = setOf(
ExtractorLinkType.VIDEO,
@@ -26,10 +28,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
)
}
-open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction(
+open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction(
txt(appName),
packageName,
- "is.xyz.mpv.MPVActivity"
+ intentClass
) {
override val oneSource = true // mpv has poor playlist support on TV
override suspend fun putExtra(
@@ -44,7 +46,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv
putExtra("title", video.name)
if (index != null) {
- setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*")
+ setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*")
} else {
makeTempM3U8Intent(context, this, result)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt
index 7c1b68c054e..bfd2926bf1c 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt
@@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.content.Context
import android.content.Intent
-import android.net.Uri
+import androidx.core.net.toUri
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
@@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() {
) {
val link = result.links.getOrNull(index ?: 0) ?: return
val i = Intent(Intent.ACTION_VIEW)
- i.data = Uri.parse(link.url)
+ i.data = link.url.toUri()
launch(i)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt
new file mode 100644
index 00000000000..56512377bae
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt
@@ -0,0 +1,65 @@
+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 = 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(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,
+ callback: (Pair) -> 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
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt
index e1fc22d3c56..46b46a2c2fe 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt
@@ -6,7 +6,7 @@ import android.content.Intent
import android.os.Build
import androidx.core.net.toUri
import com.lagradost.api.Log
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt
index 9f7eee7b820..963221bb343 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt
@@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
-import android.net.Uri
import android.os.Bundle
import androidx.core.net.toUri
import com.lagradost.cloudstream3.USER_AGENT
@@ -38,7 +37,7 @@ class WebVideoCastPackage: OpenInAppAction(
val link = result.links[index ?: 0]
intent.apply {
- setDataAndType(Uri.parse(link.url), "video/*")
+ setDataAndType(link.url.toUri(), "video/*")
val title = video.name ?: video.headerName
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt
index e3916df01c6..1036a70557c 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt
@@ -1,7 +1,7 @@
package com.lagradost.cloudstream3.actions.temp.fcast
import android.content.Context
-import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.actions.VideoClickAction
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt
index 282ef834eb2..e2cf4f002f6 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt
@@ -7,6 +7,7 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.ext.SdkExtensions
import android.util.Log
+import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
class FcastManager {
@@ -72,52 +73,66 @@ class FcastManager {
}
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
- if (serviceInfo == null) return
+ // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback
+ safe {
+ if (serviceInfo == null) return@safe
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
+ Build.VERSION_CODES.TIRAMISU
+ ) >= 7
+ ) {
+ nsdManager?.registerServiceInfoCallback(
+ serviceInfo,
+ Runnable::run,
+ object : NsdManager.ServiceInfoCallback {
+ override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
+ Log.e(tag, "Service registration failed: $errorCode")
+ }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
- Build.VERSION_CODES.TIRAMISU) >= 7) {
- nsdManager?.registerServiceInfoCallback(serviceInfo,
- Runnable::run,
- 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 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 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 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
+ override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
+ if (serviceInfo == null) return
- synchronized(_currentDevices) {
- _currentDevices.add(PublicDeviceInfo(serviceInfo))
- }
+ synchronized(_currentDevices) {
+ _currentDevices.add(PublicDeviceInfo(serviceInfo))
+ }
- Log.d(
- tag,
- "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
- )
- }
- })
+ Log.d(
+ tag,
+ "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
+ )
+ }
+ })
+ }
}
}
@@ -168,8 +183,9 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
val host: String? = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
SdkExtensions.getExtensionVersion(
- Build.VERSION_CODES.TIRAMISU) >= 7
- ) {
+ Build.VERSION_CODES.TIRAMISU
+ ) >= 7
+ ) {
serviceInfo.hostAddresses.firstOrNull()?.hostAddress
} else {
@Suppress("DEPRECATION")
diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
index 3df5197cd00..482ec05fc1b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
@@ -1,16 +1,68 @@
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.LiveData
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.viewbinding.ViewBinding
+import com.lagradost.cloudstream3.ui.BaseFragment
/** NOTE: Only one observer at a time per value */
-fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) {
- liveData.removeObservers(this)
- liveData.observe(this) { it?.let { t -> action(t) } }
+fun ComponentActivity.observe(liveData: LiveData, action: (T) -> Unit) {
+ observeNullable(liveData) { t -> t?.run(action) }
}
/** NOTE: Only one observer at a time per value */
-fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) {
+fun ComponentActivity.observeNullable(liveData: LiveData, action: (T?) -> Unit) {
liveData.removeObservers(this)
- liveData.observe(this) { action(it) }
+ liveData.observe(this, action)
+}
+
+/** NOTE: Only one observer at a time per value */
+fun BaseFragment.observe(liveData: LiveData, action: (T) -> Unit) {
+ observeNullable(liveData) { t -> t?.run(action) }
+}
+
+/**
+ * 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 BaseFragment.observeNullable(
+ liveData: LiveData, 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 View.observe(liveData: LiveData, action: (T) -> Unit) {
+ observeNullable(liveData) { t -> t?.run(action) }
+}
+
+/** NOTE: Only one observer at a time per value */
+fun View.observeNullable(liveData: LiveData, 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
index ec486d61db6..6234297d080 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
@@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.network
import android.content.Context
import androidx.preference.PreferenceManager
+import com.lagradost.cloudstream3.Prerelease
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.mvvm.safe
@@ -15,11 +16,26 @@ import org.conscrypt.Conscrypt
import java.io.File
import java.security.Security
+// Backwards compatible constructor, mark as deprecated later
fun Requests.initClient(context: Context) {
this.baseClient = buildDefaultClient(context)
}
+/** Only use ignoreSSL if you know what you are doing*/
+@Prerelease
+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 {
+ return buildDefaultClient(context, false)
+}
+
+/** Only use ignoreSSL if you know what you are doing*/
+@Prerelease
+fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
@@ -27,7 +43,11 @@ fun buildDefaultClient(context: Context): OkHttpClient {
val baseClient = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
- .ignoreAllSSLErrors()
+ .apply {
+ if (ignoreSSL) {
+ ignoreAllSSLErrors()
+ }
+ }
.cache(
// Note that you need to add a ResponseInterceptor to make this 100% active.
// The server response dictates if and when stuff should be cached.
@@ -52,11 +72,6 @@ fun buildDefaultClient(context: Context): OkHttpClient {
return baseClient
}
-//val Request.cookies: Map
-// get() {
-// return this.headers.getCookies("Cookie")
-// }
-
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
/**
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
index 1cffa7c1bfb..eae14a6c0c3 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
@@ -13,6 +13,7 @@ import android.os.Build
import android.os.Environment
import android.util.Log
import android.widget.Toast
+import androidx.annotation.WorkerThread
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -20,15 +21,17 @@ import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
-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.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.InternalAPI
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
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_OK
import com.lagradost.cloudstream3.R
@@ -43,6 +46,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
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.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
@@ -51,7 +55,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UiText
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
+import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.txt
import dalvik.system.PathClassLoader
@@ -76,6 +80,7 @@ data class PluginData(
@JsonProperty("filePath") val filePath: String,
@JsonProperty("version") val version: Int,
) {
+ @WorkerThread
fun toSitePlugin(): SitePlugin {
return SitePlugin(
this.filePath,
@@ -90,7 +95,9 @@ data class PluginData(
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
)
}
}
@@ -258,12 +265,8 @@ 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", "DEPRECATION_ERROR")
- @Deprecated(
- "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
- replaceWith = ReplaceWith("loadPlugin"),
- level = DeprecationLevel.ERROR
- )
+ @Suppress("FunctionName")
+ @InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
assertNonRecursiveCallstack()
@@ -304,6 +307,7 @@ object PluginManager {
downloadPlugin(
activity,
pluginData.onlineData.second.url,
+ pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
File(pluginData.savedData.filePath),
true
@@ -339,12 +343,8 @@ 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", "DEPRECATION_ERROR")
- @Deprecated(
- "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
- replaceWith = ReplaceWith("loadPlugin"),
- level = DeprecationLevel.ERROR
- )
+ @Suppress("FunctionName")
+ @InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
activity: Activity,
@@ -419,6 +419,7 @@ object PluginManager {
downloadPlugin(
activity,
pluginData.onlineData.second.url,
+ pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
pluginData.onlineData.first,
!pluginData.isDisabled
@@ -453,12 +454,8 @@ 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", "DEPRECATION_ERROR")
- @Deprecated(
- "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
- replaceWith = ReplaceWith("loadPlugin"),
- level = DeprecationLevel.ERROR
- )
+ @Suppress("FunctionName")
+ @InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
assertNonRecursiveCallstack()
@@ -479,13 +476,9 @@ 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", "DEPRECATION_ERROR")
+ @Suppress("FunctionName")
+ @InternalAPI
@Throws
- @Deprecated(
- "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
- replaceWith = ReplaceWith("loadPlugin"),
- level = DeprecationLevel.ERROR
- )
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
assertNonRecursiveCallstack()
@@ -504,12 +497,8 @@ 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", "DEPRECATION_ERROR")
- @Deprecated(
- "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
- replaceWith = ReplaceWith("loadPlugin"),
- level = DeprecationLevel.ERROR
- )
+ @Suppress("FunctionName")
+ @InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
assertNonRecursiveCallstack()
@@ -572,6 +561,11 @@ object PluginManager {
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!
* @return true if safe mode file is present
@@ -704,16 +698,25 @@ object PluginManager {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
}
- extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
+ synchronized(extractorApis) {
+ extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
+ }
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
}
- classLoaders.values.removeIf { v -> v == plugin }
+ synchronized(classLoaders) {
+ classLoaders.values.removeIf { v -> v == plugin }
+ }
- plugins.remove(absolutePath)
- urlPlugins.values.removeIf { v -> v == plugin }
+ synchronized(plugins) {
+ plugins.remove(absolutePath)
+ }
+
+ synchronized(urlPlugins) {
+ urlPlugins.values.removeIf { v -> v == plugin }
+ }
}
/**
@@ -743,25 +746,27 @@ object PluginManager {
suspend fun downloadPlugin(
activity: Activity,
pluginUrl: String,
+ pluginHash: String?,
internalName: String,
repositoryUrl: String,
loadPlugin: Boolean
): Boolean {
val file = getPluginPath(activity, internalName, repositoryUrl)
- return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
+ return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin)
}
suspend fun downloadPlugin(
activity: Activity,
pluginUrl: String,
+ pluginHash: String?,
internalName: String,
file: File,
- loadPlugin: Boolean
+ loadPlugin: Boolean,
): Boolean {
try {
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
- val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
+ val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false
val data = PluginData(
internalName,
@@ -808,13 +813,9 @@ 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", "DEPRECATION_ERROR")
+ @Suppress("FunctionName")
+ @InternalAPI
@Throws
- @Deprecated(
- "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
- replaceWith = ReplaceWith("loadPlugin"),
- level = DeprecationLevel.ERROR
- )
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
assertNonRecursiveCallstack()
@@ -853,6 +854,7 @@ object PluginManager {
if (downloadPlugin(
activity,
pluginData.onlineData.second.url,
+ pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
existingFile,
true
@@ -951,4 +953,4 @@ object PluginManager {
return null
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
index d92e81acdee..07d6aaa37bc 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
@@ -1,10 +1,11 @@
package com.lagradost.cloudstream3.plugins
import android.content.Context
+import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.AcraApplication.Companion.context
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app
@@ -18,16 +19,19 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
-import java.io.BufferedInputStream
import java.io.File
-import java.io.InputStream
-import java.io.OutputStream
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.security.MessageDigest
+import java.util.concurrent.atomic.AtomicInteger
/**
* Comes with the app, always available in the app, non removable.
* */
data class Repository(
+ @JsonProperty("iconUrl") val iconUrl: String?,
@JsonProperty("name") val name: String,
@JsonProperty("description") val description: String?,
@JsonProperty("manifestVersion") val manifestVersion: Int,
@@ -61,10 +65,12 @@ data class SitePlugin(
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
// These types are yet to be mapped and used, ignore for now
@JsonProperty("tvTypes") val tvTypes: List?,
+ // Most often a language tag like "en" or "zh-TW"
@JsonProperty("language") val language: String?,
@JsonProperty("iconUrl") val iconUrl: String?,
// Automatically generated by the gradle plugin
@JsonProperty("fileSize") val fileSize: Long?,
+ @JsonProperty("fileHash") val fileHash: String?,
)
@@ -73,7 +79,26 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array by lazy {
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
}
- private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
+ 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) }
+ }
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
fun convertRawGitUrl(url: String): String {
@@ -138,21 +163,52 @@ object RepositoryManager {
}.flatten()
}
+
suspend fun downloadPluginToFile(
+ context: Context,
pluginUrl: String,
- file: File
+ file: File,
+ expectedFileHash: String?
): File? {
return safeAsync {
- file.mkdirs()
+ val parentDir = file.parentFile ?: return@safeAsync null
+ parentDir.mkdirs()
- // Overwrite if exists
- if (file.exists()) {
- file.delete()
- }
- file.createNewFile()
+ // Prevent corrupting the plugin file if the operation fails
+ val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
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
}
}
@@ -200,13 +256,4 @@ object RepositoryManager {
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)
- }
- }
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt
index d1b702f4ce3..85a806f0b12 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt
@@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins
import android.util.Log
import android.widget.Toast
-import com.lagradost.cloudstream3.AcraApplication.Companion.context
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import java.security.MessageDigest
import com.lagradost.cloudstream3.app
@@ -12,87 +12,76 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
-object VotingApi { // please do not cheat the votes lol
- private const val LOGKEY = "VotingApi"
+object VotingApi {
- private const val API_DOMAIN = "https://counterapi.com/api"
+ private const val LOGKEY = "VotingApi"
+ private const val API_DOMAIN = "https://api.countify.xyz"
- private fun transformUrl(url: String): String = // dont touch or all votes get reset
+ private fun transformUrl(url: String): String =
MessageDigest
.getInstance("SHA-256")
.digest("${url}#funny-salt".toByteArray())
.fold("") { str, it -> str + "%02x".format(it) }
- suspend fun SitePlugin.getVotes(): Int {
- return getVotes(url)
- }
-
- fun SitePlugin.hasVoted(): Boolean {
- return hasVoted(url)
- }
-
- suspend fun SitePlugin.vote(): Int {
- return vote(url)
- }
+ suspend fun SitePlugin.getVotes(): Int = getVotes(url)
+ fun SitePlugin.hasVoted(): Boolean = hasVoted(url)
+ suspend fun SitePlugin.vote(): Int = vote(url)
+ fun SitePlugin.canVote(): Boolean = canVote(this.url)
- fun SitePlugin.canVote(): Boolean {
- return canVote(this.url)
- }
-
- // Plugin url to Int
private val votesCache = mutableMapOf()
- private fun getRepository(pluginUrl: String) = pluginUrl
- .split("/")
- .drop(2)
- .take(3)
- .joinToString("-")
-
private suspend fun readVote(pluginUrl: String): Int {
- val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
- Log.d(LOGKEY, "Requesting: $url")
- return app.get(url).parsedSafe()?.value ?: 0
+ val id = transformUrl(pluginUrl)
+ val url = "$API_DOMAIN/get-total/$id"
+ Log.d(LOGKEY, "Requesting GET: $url")
+ return app.get(url).parsedSafe()?.count ?: 0
}
private suspend fun writeVote(pluginUrl: String): Boolean {
- val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
- Log.d(LOGKEY, "Requesting: $url")
- return app.get(url).parsedSafe()?.value != null
+ val id = transformUrl(pluginUrl)
+ val url = "$API_DOMAIN/increment/$id"
+ Log.d(LOGKEY, "Requesting POST: $url")
+ return app.post(url, emptyMap())
+ .parsedSafe()?.count != null
}
suspend fun getVotes(pluginUrl: String): Int =
- votesCache[pluginUrl] ?: readVote(pluginUrl).also {
- votesCache[pluginUrl] = it
- }
+ votesCache[pluginUrl] ?: readVote(pluginUrl).also {
+ votesCache[pluginUrl] = it
+ }
fun hasVoted(pluginUrl: String) =
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
- fun canVote(pluginUrl: String): Boolean {
- return PluginManager.urlPlugins.contains(pluginUrl)
- }
+ fun canVote(pluginUrl: String): Boolean =
+ PluginManager.urlPlugins.contains(pluginUrl)
private val voteLock = Mutex()
+
suspend fun vote(pluginUrl: String): Int {
- // Prevent multiple requests at the same time.
voteLock.withLock {
if (!canVote(pluginUrl)) {
main {
- Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
- .show()
+ Toast.makeText(
+ context,
+ R.string.extension_install_first,
+ Toast.LENGTH_SHORT
+ ).show()
}
return getVotes(pluginUrl)
}
if (hasVoted(pluginUrl)) {
main {
- Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
- .show()
+ Toast.makeText(
+ context,
+ R.string.already_voted,
+ Toast.LENGTH_SHORT
+ ).show()
}
return getVotes(pluginUrl)
}
-
if (writeVote(pluginUrl)) {
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
@@ -102,7 +91,8 @@ object VotingApi { // please do not cheat the votes lol
}
}
- private data class Result(
- val value: Int?
+ private data class CountifyResult(
+ val id: String? = null,
+ val count: Int? = null
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt
new file mode 100644
index 00000000000..e07747a860c
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt
@@ -0,0 +1,279 @@
+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> =
+ 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> =
+ _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 ->
+ 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")
+ }
+
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
index fc31c1f3e0d..7134650ed4e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
@@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
+import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit
@@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
.build()
)
}
- @Suppress("DEPRECATION_ERROR")
+
override suspend fun doWork(): Result {
try {
// println("Update subscriptions!")
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
index 6151a0edd20..d63b18cdc97 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
@@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services
import android.app.Service
import android.content.Intent
import android.os.IBinder
-import com.lagradost.cloudstream3.utils.VideoDownloadManager
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
+/** Handle notification actions such as pause/resume downloads */
class VideoDownloadService : Service() {
private val downloadScope = CoroutineScope(Dispatchers.Default)
@@ -42,19 +43,3 @@ class VideoDownloadService : Service() {
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))
-// }
-// }
-// }
-//}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
index bfc6dacb65e..9e6f241fb95 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
@@ -1,18 +1,9 @@
package com.lagradost.cloudstream3.subtitles
-import androidx.annotation.WorkerThread
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.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.utils.Coroutines.threadSafeListOf
import okio.BufferedSource
import okio.buffer
import okio.sink
@@ -20,116 +11,6 @@ import okio.source
import java.io.File
import java.util.zip.ZipInputStream
-interface AbstractSubProvider {
- val idPrefix: String
-
- @WorkerThread
- @Throws
- suspend fun search(query: SubtitleSearch): List? {
- 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,
- 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()
- private var searchCacheIndex: Int = 0
- private val resourceCache = threadSafeListOf()
- private var resourceCacheIndex: Int = 0
- const val CACHE_SIZE = 20
- }
-
- val idPrefix: String get() = api.idPrefix
-
- @WorkerThread
- suspend fun getResource(data: SubtitleEntity): Resource = 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> {
- 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.
* @see addUrl
@@ -210,4 +91,3 @@ class SubtitleResource {
}
}
-interface AbstractSubApi : AbstractSubProvider, AuthAPI
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
index 2e14c3c46fd..3bc5f273397 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
@@ -1,149 +1,165 @@
-package com.lagradost.cloudstream3.syncproviders
-
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
-import com.lagradost.cloudstream3.LoadResponse
-import com.lagradost.cloudstream3.syncproviders.providers.*
-import java.util.concurrent.TimeUnit
-
-abstract class AccountManager(private val defIndex: Int) : AuthAPI {
- companion object {
- val malApi = MALApi(0).also { api ->
- LoadResponse.Companion.malIdPrefix = api.idPrefix
- }
- val aniListApi = AniListApi(0).also { api ->
- LoadResponse.Companion.aniListIdPrefix = api.idPrefix
- }
- val simklApi = SimklApi(0).also { api ->
- LoadResponse.Companion.simklIdPrefix = api.idPrefix
- }
- val openSubtitlesApi = OpenSubtitlesApi(0)
- val addic7ed = Addic7ed()
- val subDlApi = SubDlApi(0)
- val localListApi = LocalList()
- val subSourceApi = SubSourceApi()
-
- // used to login via app intent
- val OAuth2Apis
- get() = listOf(
- malApi, aniListApi, simklApi
- )
-
- // this needs init with context and can be accessed in settings
- val accountManagers
- get() = listOf(
- malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi
- )
-
- // used for active syncing
- val SyncApis
- get() = listOf(
- SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
- )
-
- val inAppAuths
- get() = listOf(
- openSubtitlesApi,
- subDlApi
- )//, nginxApi)
-
- val subtitleProviders
- get() = listOf(
- openSubtitlesApi,
- addic7ed,
- subDlApi,
- subSourceApi
- )
-
- const val APP_STRING = "cloudstreamapp"
- const val APP_STRING_REPO = "cloudstreamrepo"
- const val APP_STRING_PLAYER = "cloudstreamplayer"
-
- // Instantly start the search given a query
- const val APP_STRING_SEARCH = "cloudstreamsearch"
-
- // Instantly resume watching a show
- const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
-
- val unixTime: Long
- get() = System.currentTimeMillis() / 1000L
- val unixTimeMs: Long
- get() = System.currentTimeMillis()
-
- const val MAX_STALE = 60 * 10
-
- fun secondsToReadable(seconds: Int, completedValue: String): String {
- 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"
- }
- }
-
- var accountIndex = defIndex
- private var lastAccountIndex = defIndex
- protected val accountId get() = "${idPrefix}_account_$accountIndex"
- private val accountActiveKey get() = "${idPrefix}_active"
-
- // int array of all accounts indexes
- private val accountsKey get() = "${idPrefix}_accounts"
-
- protected fun removeAccountKeys() {
- removeKeys(accountId)
- val accounts = getAccounts()?.toMutableList() ?: mutableListOf()
- accounts.remove(accountIndex)
- setKey(accountsKey, accounts.toIntArray())
-
- init()
- }
-
- fun getAccounts(): IntArray? {
- return getKey(accountsKey, intArrayOf())
- }
-
- fun init() {
- accountIndex = getKey(accountActiveKey, defIndex)!!
- val accounts = getAccounts()
- if (accounts?.isNotEmpty() == true && this.loginInfo() == null) {
- accountIndex = accounts.first()
- }
- }
-
- protected fun switchToNewAccount() {
- val accounts = getAccounts()
- lastAccountIndex = accountIndex
- accountIndex = (accounts?.maxOrNull() ?: 0) + 1
- }
- protected fun switchToOldAccount() {
- accountIndex = lastAccountIndex
- }
-
- protected fun registerAccount() {
- setKey(accountActiveKey, accountIndex)
- val accounts = getAccounts()?.toMutableList() ?: mutableListOf()
- if (!accounts.contains(accountIndex)) {
- accounts.add(accountIndex)
- }
-
- setKey(accountsKey, accounts.toIntArray())
- }
-
- fun changeAccount(index: Int) {
- accountIndex = index
- setKey(accountActiveKey, index)
- }
-}
+package com.lagradost.cloudstream3.syncproviders
+
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
+import com.lagradost.cloudstream3.LoadResponse
+import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
+import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
+import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi
+import com.lagradost.cloudstream3.syncproviders.providers.LocalList
+import com.lagradost.cloudstream3.syncproviders.providers.MALApi
+import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
+import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
+import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
+import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth
+import java.util.concurrent.TimeUnit
+
+abstract class AccountManager {
+ companion object {
+ const val NONE_ID: Int = -1
+ val malApi = MALApi()
+ val kitsuApi = KitsuApi()
+ val aniListApi = AniListApi()
+ val simklApi = SimklApi()
+ val localListApi = LocalList()
+
+ val openSubtitlesApi = OpenSubtitlesApi()
+ val addic7ed = Addic7ed()
+ val subDlApi = SubDlApi()
+ val subSourceApi = SubSourceApi()
+ val animeSkipApi = AnimeSkipAuth()
+
+ var cachedAccounts: MutableMap>
+ var cachedAccountIds: MutableMap
+
+ const val ACCOUNT_TOKEN = "auth_tokens"
+ const val ACCOUNT_IDS = "auth_ids"
+
+ fun accounts(prefix: String): Array {
+ require(prefix != "NONE")
+ return getKey>(
+ ACCOUNT_TOKEN,
+ "${prefix}/${DataStoreHelper.currentAccount}"
+ ) ?: arrayOf()
+ }
+
+ fun updateAccounts(prefix: String, array: Array) {
+ require(prefix != "NONE")
+ setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array)
+ synchronized(cachedAccounts) {
+ cachedAccounts[prefix] = array
+ }
+ }
+
+ fun updateAccountsId(prefix: String, id: Int) {
+ require(prefix != "NONE")
+ setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id)
+ synchronized(cachedAccountIds) {
+ cachedAccountIds[prefix] = id
+ }
+ }
+
+ val allApis = arrayOf(
+ SyncRepo(malApi),
+ SyncRepo(kitsuApi),
+ SyncRepo(aniListApi),
+ SyncRepo(simklApi),
+ SyncRepo(localListApi),
+ SubtitleRepo(openSubtitlesApi),
+ SubtitleRepo(addic7ed),
+ SubtitleRepo(subDlApi),
+ PlainAuthRepo(animeSkipApi)
+ )
+
+ fun updateAccountIds() {
+ val ids = mutableMapOf()
+ for (api in allApis) {
+ ids.put(
+ api.idPrefix,
+ getKey(
+ ACCOUNT_IDS,
+ "${api.idPrefix}/${DataStoreHelper.currentAccount}",
+ NONE_ID
+ ) ?: NONE_ID
+ )
+ }
+ synchronized(cachedAccountIds) {
+ cachedAccountIds = ids
+ }
+ }
+
+ init {
+ val data = mutableMapOf>()
+ val ids = mutableMapOf()
+ for (api in allApis) {
+ data.put(api.idPrefix, accounts(api.idPrefix))
+ ids.put(
+ api.idPrefix,
+ getKey(
+ ACCOUNT_IDS,
+ "${api.idPrefix}/${DataStoreHelper.currentAccount}",
+ NONE_ID
+ ) ?: NONE_ID
+ )
+ }
+ cachedAccounts = data
+ cachedAccountIds = ids
+ }
+
+ // 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() {
+ LoadResponse.malIdPrefix = malApi.idPrefix
+ LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix
+ LoadResponse.aniListIdPrefix = aniListApi.idPrefix
+ LoadResponse.simklIdPrefix = simklApi.idPrefix
+ }
+
+ val subtitleProviders = arrayOf(
+ SubtitleRepo(openSubtitlesApi),
+ SubtitleRepo(addic7ed),
+ SubtitleRepo(subDlApi)
+ )
+ val syncApis = arrayOf(
+ SyncRepo(malApi),
+ SyncRepo(kitsuApi),
+ SyncRepo(aniListApi),
+ SyncRepo(simklApi),
+ SyncRepo(localListApi)
+ )
+
+ const val APP_STRING = "cloudstreamapp"
+ const val APP_STRING_REPO = "cloudstreamrepo"
+ const val APP_STRING_PLAYER = "cloudstreamplayer"
+
+ // Instantly start the search given a query
+ const val APP_STRING_SEARCH = "cloudstreamsearch"
+
+ // Instantly resume watching a show
+ const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
+
+ const val APP_STRING_SHARE = "csshare"
+
+ fun secondsToReadable(seconds: Int, completedValue: String): String {
+ 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"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt
index 8b085bc0b83..83a7a09847c 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt
@@ -1,23 +1,282 @@
package com.lagradost.cloudstream3.syncproviders
-interface AuthAPI {
- val name: String
- val icon: Int?
+import android.util.Base64
+import androidx.annotation.WorkerThread
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.cloudstream3.APIHolder.unixTime
+import com.lagradost.cloudstream3.ActorData
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
+import com.lagradost.cloudstream3.CommonActivity.showToast
+import com.lagradost.cloudstream3.ErrorLoadingException
+import com.lagradost.cloudstream3.LoadResponse
+import com.lagradost.cloudstream3.NextAiring
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.Score
+import com.lagradost.cloudstream3.SearchQuality
+import com.lagradost.cloudstream3.SearchResponse
+import com.lagradost.cloudstream3.ShowStatus
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.mvvm.Resource
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.mvvm.safe
+import com.lagradost.cloudstream3.mvvm.safeApiCall
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
+import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
+import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
+import com.lagradost.cloudstream3.syncproviders.providers.LocalList
+import com.lagradost.cloudstream3.syncproviders.providers.MALApi
+import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi
+import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
+import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
+import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
+import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
+import com.lagradost.cloudstream3.ui.SyncWatchType
+import com.lagradost.cloudstream3.ui.library.ListSorting
+import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
+import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.UiText
+import com.lagradost.cloudstream3.utils.txt
+import me.xdrop.fuzzywuzzy.FuzzySearch
+import java.net.URL
+import java.security.SecureRandom
+import java.util.Date
+import java.util.concurrent.TimeUnit
- val requiresLogin: Boolean
+data class AuthLoginPage(
+ /** 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,
+)
- 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")
+ val accessToken: String? = null,
+ /**
+ * For OAuth a special refresh token is issues to refresh the access token.
+ * */
+ @JsonProperty("refreshToken")
+ val refreshToken: String? = null,
+ /** In UnixTime (sec) when it expires */
+ @JsonProperty("accessTokenLifetime")
+ val accessTokenLifetime: Long? = null,
+ /** In UnixTime (sec) when it expires */
+ @JsonProperty("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")
+ val payload: String? = null,
+) {
+ fun isAccessTokenExpired(marginSec: Long = 10L) =
+ accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime
- // don't change this as all keys depend on it
- val idPrefix: String
+ fun isRefreshTokenExpired(marginSec: Long = 10L) =
+ refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime
+}
- // if this returns null then you are not logged in
- fun loginInfo(): LoginInfo?
- fun logOut()
+data class AuthUser(
+ /** Account display-name, can also be email if name does not exist */
+ @JsonProperty("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")
+ val id: Int,
+ /** Profile picture URL */
+ @JsonProperty("profilePicture")
+ val profilePicture: String? = null,
+ /** Profile picture Headers of the URL */
+ @JsonProperty("profilePictureHeader")
+ val profilePictureHeaders: Map? = 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.
+ * */
+data class AuthData(
+ @JsonProperty("user")
+ val user: AuthUser,
+ @JsonProperty("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 */
+data class AuthLoginResponse(
+ @JsonProperty("password")
+ val password: String?,
+ @JsonProperty("username")
+ val username: String?,
+ @JsonProperty("email")
+ val email: String?,
+ @JsonProperty("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 {
+ val unixTime: Long
+ get() = System.currentTimeMillis() / 1000L
+ val unixTimeMs: Long
+ get() = System.currentTimeMillis()
+
+ fun splitRedirectUrl(redirectUrl: String): Map {
+ return splitQuery(
+ URL(
+ 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 Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).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(
val profilePicture: String? = null,
val name: String?,
val accountIndex: Int,
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt
new file mode 100644
index 00000000000..645a19e3a60
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt
@@ -0,0 +1,168 @@
+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 = 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
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt
new file mode 100644
index 00000000000..5efb88e5b74
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt
@@ -0,0 +1,14 @@
+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()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt
deleted file mode 100644
index 8b6fdf463cf..00000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-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()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt
deleted file mode 100644
index 3d0bb9402cc..00000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-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,
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt
new file mode 100644
index 00000000000..a1149b5f8f8
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt
@@ -0,0 +1,37 @@
+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? =
+ 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)
+ }
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt
new file mode 100644
index 00000000000..7a93f96f697
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt
@@ -0,0 +1,89 @@
+package com.lagradost.cloudstream3.syncproviders
+
+import androidx.annotation.WorkerThread
+import com.lagradost.cloudstream3.APIHolder.unixTime
+import com.lagradost.cloudstream3.ErrorLoadingException
+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.threadSafeListOf
+
+/** Stateless safe abstraction of SubtitleAPI */
+class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
+ companion object {
+ data class SavedSearchResponse(
+ val unixTime: Long,
+ val response: List,
+ 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()
+ private var searchCacheIndex: Int = 0
+ private val resourceCache = threadSafeListOf()
+ private var resourceCacheIndex: Int = 0
+ const val CACHE_SIZE = 20
+ }
+
+ @WorkerThread
+ suspend fun resource(data: SubtitleEntity): Result = runCatching {
+ synchronized(resourceCache) {
+ for (item in resourceCache) {
+ // 20 min save
+ if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
+ return@runCatching item.response
+ }
+ }
+ }
+
+ val returnValue = api.resource(freshAuth(), 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): Result> {
+ return runCatching {
+ synchronized(searchCache) {
+ for (item in searchCache) {
+ // 120 min save
+ if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
+ return@runCatching item.response
+ }
+ }
+ }
+
+ val returnValue =
+ api.search(freshAuth(), query) ?: emptyList()
+
+ // 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
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt
similarity index 64%
rename from app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt
rename to app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt
index 9d43685c830..e5f9aca8493 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt
@@ -1,170 +1,194 @@
-package com.lagradost.cloudstream3.syncproviders
-
-import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.ui.SyncWatchType
-import com.lagradost.cloudstream3.ui.library.ListSorting
-import com.lagradost.cloudstream3.utils.UiText
-import me.xdrop.fuzzywuzzy.FuzzySearch
-import java.util.Date
-
-interface SyncAPI : OAuth2API {
- /**
- * Set this to true if the user updates something on the list like watch status or score
- **/
- var requireLibraryRefresh: Boolean
- val mainUrl: String
-
- /**
- * Allows certain providers to open pages from
- * library links.
- **/
- val syncIdName: SyncIdName
-
- /**
- -1 -> None
- 0 -> Watching
- 1 -> Completed
- 2 -> OnHold
- 3 -> Dropped
- 4 -> PlanToWatch
- 5 -> ReWatching
- */
- suspend fun score(id: String, status: AbstractSyncStatus): Boolean
-
- suspend fun getStatus(id: String): AbstractSyncStatus?
-
- suspend fun getResult(id: String): SyncResult?
-
- suspend fun search(name: String): List?
-
- suspend fun getPersonalLibrary(): LibraryMetadata?
-
- fun getIdFromUrl(url: String): String
-
- data class SyncSearchResult(
- override val name: String,
- override val apiName: String,
- var syncId: String,
- override val url: String,
- override var posterUrl: String?,
- override var type: TvType? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
- override var id: Int? = null,
- ) : SearchResponse
-
- abstract class AbstractSyncStatus {
- abstract var status: SyncWatchType
-
- /** 1-10 */
- abstract var score: Int?
- abstract var watchedEpisodes: Int?
- abstract var isFavorite: Boolean?
- abstract var maxEpisodes: Int?
- }
-
-
- data class SyncStatus(
- override var status: SyncWatchType,
- /** 1-10 */
- override var score: Int?,
- override var watchedEpisodes: Int?,
- override var isFavorite: Boolean? = null,
- override var maxEpisodes: Int? = null,
- ) : AbstractSyncStatus()
-
- data class SyncResult(
- /**Used to verify*/
- var id: String,
-
- var totalEpisodes: Int? = null,
-
- var title: String? = null,
- /**1-1000*/
- var publicScore: Int? = null,
- /**In minutes*/
- var duration: Int? = null,
- var synopsis: String? = null,
- var airStatus: ShowStatus? = null,
- var nextAiring: NextAiring? = null,
- var studio: List? = null,
- var genres: List? = null,
- var synonyms: List? = null,
- var trailers: List? = null,
- var isAdult: Boolean? = null,
- var posterUrl: String? = null,
- var backgroundPosterUrl: String? = null,
-
- /** In unixtime */
- var startDate: Long? = null,
- /** In unixtime */
- var endDate: Long? = null,
- var recommendations: List? = null,
- var nextSeason: SyncSearchResult? = null,
- var prevSeason: SyncSearchResult? = null,
- var actors: List? = null,
- )
-
-
- data class Page(
- val title: UiText, var items: List
- ) {
- fun sort(method: ListSorting?, query: String? = null) {
- items = when (method) {
- ListSorting.Query ->
- if (query != null) {
- items.sortedBy {
- -FuzzySearch.partialRatio(
- query.lowercase(), it.name.lowercase()
- )
- }
- } else items
- ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating ?: 0) }
- ListSorting.RatingLow -> items.sortedBy { (it.personalRating ?: 0) }
- ListSorting.AlphabeticalA -> items.sortedBy { it.name }
- ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
- ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
- ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
- ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
- ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
- else -> items
- }
- }
- }
-
- data class LibraryMetadata(
- val allLibraryLists: List,
- val supportedListSorting: Set
- )
-
- data class LibraryList(
- val name: UiText,
- val items: List
- )
-
- 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?,
- /** Out of 100 */
- val personalRating: Int?,
- val lastUpdatedUnixTime: Long?,
- override val apiName: String,
- override var type: TvType?,
- override var posterUrl: String?,
- override var posterHeaders: Map?,
- override var quality: SearchQuality?,
- val releaseDate: Date?,
- override var id: Int? = null,
- val plot : String? = null,
- val rating: Int? = null,
- val tags: List? = null
- ) : SearchResponse
+package com.lagradost.cloudstream3.syncproviders
+
+import androidx.annotation.WorkerThread
+import com.lagradost.cloudstream3.ActorData
+import com.lagradost.cloudstream3.NextAiring
+import com.lagradost.cloudstream3.Score
+import com.lagradost.cloudstream3.SearchQuality
+import com.lagradost.cloudstream3.SearchResponse
+import com.lagradost.cloudstream3.ShowStatus
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.ui.SyncWatchType
+import com.lagradost.cloudstream3.ui.library.ListSorting
+import com.lagradost.cloudstream3.utils.UiText
+import me.xdrop.fuzzywuzzy.FuzzySearch
+import java.util.Date
+
+/**
+ * Stateless synchronization class, used for syncing status about a specific movie/show.
+ *
+ * All non-null `AuthToken` will be non-expired when each function is called.
+ */
+abstract class SyncAPI : AuthAPI() {
+ /**
+ * Set this to true if the user updates something on the list like watch status or score
+ **/
+ open var requireLibraryRefresh: Boolean = true
+ open val mainUrl: String = "NONE"
+
+ /** Currently unused, but will be used to correctly render the UI.
+ * This should specify what sync watch types can be used with this service. */
+ open val supportedWatchTypes: Set = SyncWatchType.entries.toSet()
+ /**
+ * Allows certain providers to open pages from
+ * library links.
+ **/
+ open val syncIdName: SyncIdName? = null
+
+ /** Modify the current status of an item */
+ @Throws
+ @WorkerThread
+ open suspend fun updateStatus(
+ auth: AuthData?,
+ id: String,
+ newStatus: AbstractSyncStatus
+ ): Boolean = throw NotImplementedError()
+
+ /** Get the current status of an item */
+ @Throws
+ @WorkerThread
+ open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? =
+ throw NotImplementedError()
+
+ /** Get metadata about an item */
+ @Throws
+ @WorkerThread
+ open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError()
+
+ /** Search this service for any results for a given query */
+ @Throws
+ @WorkerThread
+ open suspend fun search(auth: AuthData?, query: String): List? =
+ throw NotImplementedError()
+
+ /** Get the current library/bookmarks of this service */
+ @Throws
+ @WorkerThread
+ open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError()
+
+ /** Helper function, may be used in the future */
+ @Throws
+ open fun urlToId(url: String): String? = null
+
+ data class SyncSearchResult(
+ override val name: String,
+ override val apiName: String,
+ var syncId: String,
+ override val url: String,
+ override var posterUrl: String?,
+ override var type: TvType? = null,
+ override var quality: SearchQuality? = null,
+ override var posterHeaders: Map? = null,
+ override var id: Int? = null,
+ override var score: Score? = null,
+ ) : SearchResponse
+
+ abstract class AbstractSyncStatus {
+ abstract var status: SyncWatchType
+ abstract var score: Score?
+ abstract var watchedEpisodes: Int?
+ abstract var isFavorite: Boolean?
+ abstract var maxEpisodes: Int?
+ }
+
+ data class SyncStatus(
+ override var status: SyncWatchType,
+ override var score: Score?,
+ override var watchedEpisodes: Int?,
+ override var isFavorite: Boolean? = null,
+ override var maxEpisodes: Int? = null,
+ ) : AbstractSyncStatus()
+
+ data class SyncResult(
+ /**Used to verify*/
+ var id: String,
+
+ var totalEpisodes: Int? = null,
+
+ var title: String? = null,
+ var publicScore: Score? = null,
+ /**In minutes*/
+ var duration: Int? = null,
+ var synopsis: String? = null,
+ var airStatus: ShowStatus? = null,
+ var nextAiring: NextAiring? = null,
+ var studio: List? = null,
+ var genres: List? = null,
+ var synonyms: List? = null,
+ var trailers: List? = null,
+ var isAdult: Boolean? = null,
+ var posterUrl: String? = null,
+ var backgroundPosterUrl: String? = null,
+
+ /** In unixtime */
+ var startDate: Long? = null,
+ /** In unixtime */
+ var endDate: Long? = null,
+ var recommendations: List? = null,
+ var nextSeason: SyncSearchResult? = null,
+ var prevSeason: SyncSearchResult? = null,
+ var actors: List? = null,
+ )
+
+ data class Page(
+ val title: UiText, var items: List
+ ) {
+ fun sort(method: ListSorting?, query: String? = null) {
+ items = when (method) {
+ ListSorting.Query ->
+ if (query != null) {
+ items.sortedBy {
+ -FuzzySearch.partialRatio(
+ query.lowercase(), it.name.lowercase()
+ )
+ }
+ } else items
+
+ ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) }
+ ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) }
+ ListSorting.AlphabeticalA -> items.sortedBy { it.name }
+ ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
+ ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
+ ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
+ ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
+ ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
+ else -> items
+ }
+ }
+ }
+
+ data class LibraryMetadata(
+ val allLibraryLists: List,
+ val supportedListSorting: Set
+ )
+
+ data class LibraryList(
+ val name: UiText,
+ val items: List
+ )
+
+ 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?,
+ override var quality: SearchQuality?,
+ val releaseDate: Date?,
+ override var id: Int? = null,
+ val plot: String? = null,
+ override var score: Score? = null,
+ val tags: List? = null
+ ) : SearchResponse
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
index df88eeb71ef..de82624fc7e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
@@ -1,48 +1,30 @@
-package com.lagradost.cloudstream3.syncproviders
-
-import com.lagradost.cloudstream3.ErrorLoadingException
-import com.lagradost.cloudstream3.mvvm.Resource
-import com.lagradost.cloudstream3.mvvm.safe
-import com.lagradost.cloudstream3.mvvm.safeApiCall
-
-class SyncRepo(private val repo: SyncAPI) {
- val idPrefix = repo.idPrefix
- val name = repo.name
- val icon = repo.icon
- val mainUrl = repo.mainUrl
- val requiresLogin = repo.requiresLogin
- val syncIdName = repo.syncIdName
- var requireLibraryRefresh: Boolean
- get() = repo.requireLibraryRefresh
- set(value) {
- repo.requireLibraryRefresh = value
- }
-
- suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource {
- return safeApiCall { repo.score(id, status) }
- }
-
- suspend fun getStatus(id: String): Resource {
- return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
- }
-
- suspend fun getResult(id: String): Resource {
- return safeApiCall { repo.getResult(id) ?: throw ErrorLoadingException("No data") }
- }
-
- suspend fun search(query: String): Resource> {
- return safeApiCall { repo.search(query) ?: throw ErrorLoadingException() }
- }
-
- suspend fun getPersonalLibrary(): Resource {
- return safeApiCall { repo.getPersonalLibrary() ?: throw ErrorLoadingException() }
- }
-
- fun hasAccount(): Boolean {
- return safe { repo.loginInfo() != null } ?: false
- }
-
- fun getIdFromUrl(url: String): String? = safe {
- repo.getIdFromUrl(url)
- }
-}
\ No newline at end of file
+package com.lagradost.cloudstream3.syncproviders
+
+/** Stateless safe abstraction of SyncAPI */
+class SyncRepo(override val api: SyncAPI) : AuthRepo(api) {
+ val syncIdName = api.syncIdName
+ var requireLibraryRefresh: Boolean
+ get() = api.requireLibraryRefresh
+ set(value) {
+ api.requireLibraryRefresh = value
+ }
+
+ suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result =
+ runCatching {
+ val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus)
+ requireLibraryRefresh = true
+ status
+ }
+
+ suspend fun status(id: String): Result = runCatching {
+ api.status(freshAuth(), id)
+ }
+
+ suspend fun load(id: String): Result = runCatching {
+ api.load(freshAuth(), id)
+ }
+
+ suspend fun library(): Result = runCatching {
+ api.library(freshAuth())
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt
index db4676393ab..144efff99ce 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt
@@ -1,108 +1,205 @@
package com.lagradost.cloudstream3.syncproviders.providers
-import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.AllLanguagesName
import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.subtitles.AbstractSubApi
-import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
-import com.lagradost.cloudstream3.utils.SubtitleHelper
+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.utils.SubtitleHelper.fromTagToEnglishLanguageName
-class Addic7ed : AbstractSubApi {
+class Addic7ed : SubtitleAPI() {
override val name = "Addic7ed"
override val idPrefix = "addic7ed"
override val requiresLogin = false
- override val icon: Nothing? = null
- override val createAccountUrl: Nothing? = null
-
- override fun loginInfo(): Nothing? = null
-
- override fun logOut() {}
companion object {
const val HOST = "https://www.addic7ed.com"
const val TAG = "ADDIC7ED"
}
- private fun fixUrl(url: String): String {
+ private fun String.fixUrl(): String {
+ val url = this
return if (url.startsWith("/")) HOST + url
else if (!url.startsWith("http")) "$HOST/$url"
else url
-
}
- override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List {
- val lang = query.lang
- val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
- val queryText = query.query.trim()
+ override suspend fun search(
+ auth: AuthData?,
+ query: SubtitleSearch
+ ): List? {
+ 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 seasonNum = query.seasonNumber ?: 0
val yearNum = query.year ?: 0
+ val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title
+ var downloadPage = ""
- fun cleanResources(
- results: MutableList,
- name: String,
- link: String,
- headers: Map,
+ fun newSubtitleEntity (
+ displayName: String?,
+ link: String?,
isHearingImpaired: Boolean
- ) {
- results.add(
- AbstractSubtitleEntities.SubtitleEntity(
- idPrefix = idPrefix,
- name = name,
- lang = queryLang.toString(),
- data = link,
- source = this.name,
- type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
- epNumber = epNum,
- seasonNumber = seasonNum,
- year = yearNum,
- headers = headers,
- isHearingImpaired = isHearingImpaired
- )
+ ): SubtitleEntity? {
+ if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null
+ return SubtitleEntity(
+ idPrefix = this.idPrefix,
+ name = displayName,
+ lang = langTagIETF,
+ data = link,
+ source = this.name,
+ type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
+ epNumber = epNum,
+ seasonNumber = seasonNum,
+ year = yearNum,
+ headers = mapOf("referer" to "$HOST/"),
+ isHearingImpaired = isHearingImpaired
)
}
- val title = queryText.substringBefore("(").trim()
- val url = "$HOST/search.php?search=${title}&Submit=Search"
- val hostDocument = app.get(url).document
- var searchResult = ""
- if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
- else if (!hostDocument.select("table.tabel")
- .isNullOrEmpty()
- ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString()
- else {
- val show =
- hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
- ?.substringBefore(",")
+ val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search")
+ val hostDocument = response.document
+
+ // 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name
+ if (response.url.contains("/movie/") || response.url.contains("/serie/"))
+ downloadPage = response.url
+
+ // 2nd case: found tv series ep list. Redirected to $HOST/show/1234
+ else if (response.url.contains("/show/")) {
+ val showId = response.url.substringAfterLast("/")
val doc = app.get(
- "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
+ "$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0",
referer = "$HOST/"
).document
- doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
- if (node.selectFirst("td")?.text()
- ?.toIntOrNull() == seasonNum && node.select("td:eq(1)")
- .text()
- .toIntOrNull() == epNum
- ) searchResult = fixUrl(node.select("a").attr("href"))
+
+ // get direct subtitles links from list
+ return doc.select("#season tbody tr").mapNotNull { node ->
+ if (node.select("td:eq(1)").text().toIntOrNull() == epNum)
+ newSubtitleEntity(
+ displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(),
+ 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()
- val document = app.get(
- url = fixUrl(searchResult),
- ).document
- document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node ->
- val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${
- node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")
- }" 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"))
+ // filter download page by language. Do not work for movies :/
+ if (downloadPage.contains("/serie/"))
+ downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed"
+ val doc = app.get(url = downloadPage).document
+
+ // 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 =
- !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
- cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
+ node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty()
+
+ newSubtitleEntity(displayName, link, isHearingImpaired)
}
- return results
}
- override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String {
- return data.data
+ override suspend fun load(
+ auth: AuthData?,
+ 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)"),
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
index f8e82409522..7a46b411376 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
@@ -1,92 +1,89 @@
package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes
-import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.Actor
+import com.lagradost.cloudstream3.ActorData
+import com.lagradost.cloudstream3.ActorRole
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
+import com.lagradost.cloudstream3.ErrorLoadingException
+import com.lagradost.cloudstream3.NextAiring
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.Score
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.mvvm.safeAsync
-import com.lagradost.cloudstream3.syncproviders.AccountManager
-import com.lagradost.cloudstream3.syncproviders.AuthAPI
+import com.lagradost.cloudstream3.syncproviders.AuthData
+import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
+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.txt
-import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
-import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
-import java.net.URL
+import com.lagradost.cloudstream3.utils.txt
import java.net.URLEncoder
import java.util.Locale
-class AniListApi(index: Int) : AccountManager(index), SyncAPI {
+class AniListApi : SyncAPI() {
override var name = "AniList"
- override val key = "6871"
- override val redirectUrl = "anilistlogin"
override val idPrefix = "anilist"
+
+ val key = "6871"
+ override val redirectUrlIdentifier = "anilistlogin"
override var requireLibraryRefresh = true
- override val supportDeviceAuth = false
+ override val hasOAuth2 = true
override var mainUrl = "https://anilist.co"
override val icon = R.drawable.ic_anilist_icon
- override val requiresLogin = false
override val createAccountUrl = "$mainUrl/signup"
override val syncIdName = SyncIdName.Anilist
- override fun loginInfo(): AuthAPI.LoginInfo? {
- // context.getUser(true)?.
- getKey(accountId, ANILIST_USER_KEY)?.let { user ->
- return AuthAPI.LoginInfo(
- profilePicture = user.picture,
- name = user.name,
- accountIndex = accountIndex
- )
- }
- return null
- }
+ override fun loginRequest(): AuthLoginPage? =
+ AuthLoginPage("https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token")
- override fun logOut() {
- requireLibraryRefresh = true
- removeAccountKeys()
+ override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
+ val sanitizer = splitRedirectUrl(redirectUrl)
+ val token = AuthToken(
+ accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"),
+ //refreshToken = sanitizer["refresh_token"],
+ accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
+ )
+ return token
}
- override fun authenticate(activity: FragmentActivity?) {
- val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token"
- openBrowser(request, activity)
+ // https://docs.anilist.co/guide/auth/
+ override suspend fun refreshToken(token: AuthToken): AuthToken? {
+ // AniList access tokens are long-lived. They will remain valid for 1 year from the time they are issued.
+ // Refresh tokens are not currently supported. Once a token expires, you will need to re-authenticate your users.
+ return super.refreshToken(token)
}
- override suspend fun handleRedirect(url: String): Boolean {
- val sanitizer =
- splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
- val token = sanitizer["access_token"]!!
- val expiresIn = sanitizer["expires_in"]!!
-
- val endTime = unixTime + expiresIn.toLong()
+ override suspend fun user(token: AuthToken?): AuthUser? {
+ val user = getUser(token ?: return null)
+ ?: throw ErrorLoadingException("Unable to fetch user data")
- switchToNewAccount()
- setKey(accountId, ANILIST_UNIXTIME_KEY, endTime)
- setKey(accountId, ANILIST_TOKEN_KEY, token)
- val user = getUser()
- requireLibraryRefresh = true
- return user != null
+ return AuthUser(
+ id = user.id,
+ name = user.name,
+ profilePicture = user.picture,
+ )
}
- override fun getIdFromUrl(url: String): String {
- return url.removePrefix("$mainUrl/anime/").removeSuffix("/")
- }
+ override fun urlToId(url: String): String? =
+ url.removePrefix("$mainUrl/anime/").removeSuffix("/")
+
private fun getUrlFromId(id: Int): String {
return "$mainUrl/anime/$id"
}
- override suspend fun search(name: String): List? {
+ override suspend fun search(auth : AuthData?, query: String): List? {
val data = searchShows(name) ?: return null
return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult(
@@ -99,7 +96,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
}
- override suspend fun getResult(id: String): SyncAPI.SyncResult {
+ override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? {
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
val season = getSeason(internalId).data.media
@@ -141,7 +138,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
)
},
- publicScore = season.averageScore?.times(100),
+ publicScore = Score.from100(season.averageScore),
recommendations = season.recommendations?.edges?.mapNotNull { rec ->
val recMedia = rec.node.mediaRecommendation
SyncAPI.SyncSearchResult(
@@ -161,12 +158,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
+ override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null
- val data = getDataAboutId(internalId) ?: return null
+ val data = getDataAboutId(auth ?: return null, internalId) ?: return null
return SyncAPI.SyncStatus(
- score = data.score,
+ score = Score.from100(data.score),
watchedEpisodes = data.progress,
status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
isFavorite = data.isFavourite,
@@ -174,24 +171,25 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
+ override suspend fun updateStatus(
+ auth: AuthData?,
+ id: String,
+ newStatus: AbstractSyncStatus
+ ): Boolean {
return postDataAboutId(
+ auth ?: return false,
id.toIntOrNull() ?: return false,
- fromIntToAnimeStatus(status.status.internalId),
- status.score,
- status.watchedEpisodes
- ).also {
- requireLibraryRefresh = requireLibraryRefresh || it
- }
+ fromIntToAnimeStatus(newStatus.status.internalId),
+ newStatus.score,
+ newStatus.watchedEpisodes
+ )
}
companion object {
+ const val MAX_STALE = 60 * 10
private val aniListStatusString =
arrayOf("CURRENT", "COMPLETED", "PAUSED", "DROPPED", "PLANNING", "REPEATING")
- const val ANILIST_UNIXTIME_KEY: String = "anilist_unixtime" // When token expires
- const val ANILIST_TOKEN_KEY: String = "anilist_token" // anilist token for api
- const val ANILIST_USER_KEY: String = "anilist_user" // user data like profile
const val ANILIST_CACHED_LIST: String = "anilist_cached_list"
private fun fixName(name: String): String {
@@ -461,21 +459,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
}
- fun initGetUser() {
- if (getAuth() == null) return
- ioSafe {
- getUser()
- }
- }
-
- private fun checkToken(): Boolean {
- return unixTime > getKey(
- accountId,
- ANILIST_UNIXTIME_KEY, 0L
- )!!
- }
-
- private suspend fun getDataAboutId(id: Int): AniListTitleHolder? {
+ private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? {
val q =
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
@@ -485,7 +469,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
mediaListEntry {
progress
status
- score (format: POINT_10)
+ score (format: POINT_100)
}
title {
english
@@ -494,7 +478,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
}"""
- val data = postApi(q, true)
+ val data = postApi(auth.token, q, true)
val d = parseJson(data ?: return null)
val main = d.data?.media
@@ -522,37 +506,24 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
- private fun getAuth(): String? {
- return getKey(
- accountId,
- ANILIST_TOKEN_KEY
- )
+ private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? {
+ return app.post(
+ "https://graphql.anilist.co/",
+ headers = mapOf(
+ "Authorization" to "Bearer ${token.accessToken ?: return null}",
+ if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
+ ),
+ cacheTime = 0,
+ data = mapOf(
+ "query" to URLEncoder.encode(
+ q,
+ "UTF-8"
+ )
+ ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
+ timeout = 5 // REASONABLE TIMEOUT
+ ).text.replace("\\/", "/")
}
- private suspend fun postApi(q: String, cache: Boolean = false): String? {
- return safeAsync {
- if (!checkToken()) {
- app.post(
- "https://graphql.anilist.co/",
- headers = mapOf(
- "Authorization" to "Bearer " + (getAuth()
- ?: return@safeAsync null),
- if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
- ),
- cacheTime = 0,
- data = mapOf(
- "query" to URLEncoder.encode(
- q,
- "UTF-8"
- )
- ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
- timeout = 5 // REASONABLE TIMEOUT
- ).text.replace("\\/", "/")
- } else {
- null
- }
- }
- }
data class MediaRecommendation(
@JsonProperty("id") val id: Int,
@@ -624,7 +595,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
this.media.id.toString(),
this.progress,
this.media.episodes,
- this.score,
+ Score.from100(this.score),
this.updatedAt.toLong(),
"AniList",
TvType.Anime,
@@ -652,27 +623,23 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
)
- private fun getAniListListCached(): Array? {
- return getKey(ANILIST_CACHED_LIST) as? Array
- }
-
- private suspend fun getAniListAnimeListSmart(): Array? {
- if (getAuth() == null) return null
-
- if (checkToken()) return null
+ private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? {
return if (requireLibraryRefresh) {
- val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
+ val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray()
if (list != null) {
- setKey(ANILIST_CACHED_LIST, list)
+ setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list)
}
list
} else {
- getAniListListCached()
+ getKey>(
+ ANILIST_CACHED_LIST,
+ auth.user.id.toString()
+ ) as? Array
}
}
- override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
- val list = getAniListAnimeListSmart()?.groupBy {
+ override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? {
+ val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
convertAniListStringToStatus(it.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten()
@@ -699,10 +666,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- private suspend fun getFullAniListList(): FullAnilistList? {
- /** WARNING ASSUMES ONE USER! **/
-
- val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return null
+ private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? {
+ val userID = auth.user.id
val mediaType = "ANIME"
val query = """
@@ -745,11 +710,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
}
"""
- val text = postApi(query)
+ val text = postApi(auth.token, query)
return text?.toKotlinObject()
}
- suspend fun toggleLike(id: Int): Boolean {
+ suspend fun toggleLike(auth : AuthData, id: Int): Boolean {
val q = """mutation (${'$'}animeId: Int = $id) {
ToggleFavourite (animeId: ${'$'}animeId) {
anime {
@@ -762,7 +727,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
}
}"""
- val data = postApi(q)
+ val data = postApi(auth.token, q)
return data != ""
}
@@ -772,15 +737,17 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
data class MediaListId(@JsonProperty("id") val id: Long? = null)
private suspend fun postDataAboutId(
+ auth : AuthData,
id: Int,
type: AniListStatusType,
- score: Int?,
+ score: Score?,
progress: Int?
): Boolean {
+ val userID = auth.user.id
+
val q =
// Delete item if status type is None
if (type == AniListStatusType.None) {
- val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return false
// Get list ID for deletion
val idQuery = """
query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) {
@@ -789,7 +756,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
}
"""
- val response = postApi(idQuery)
+ val response = postApi(auth.token, idQuery)
val listId =
tryParseJson(response)?.data?.mediaList?.id ?: return false
"""
@@ -805,7 +772,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
0,
type.value
)]
- }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
+ }, ${if (score != null) "${'$'}scoreRaw: Int = ${score.toInt(100)}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
id
status
@@ -815,11 +782,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}"""
}
- val data = postApi(q)
+ val data = postApi(auth.token, q)
return data != ""
}
- private suspend fun getUser(setSettings: Boolean = true): AniListUser? {
+ private suspend fun getUser(token : AuthToken): AniListUser? {
val q = """
{
Viewer {
@@ -837,23 +804,15 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
}
}"""
- val data = postApi(q)
+ val data = postApi(token, q)
if (data.isNullOrBlank()) return null
val userData = parseJson(data)
- val u = userData.data?.viewer
+ val u = userData.data?.viewer ?: return null
val user = AniListUser(
- u?.id,
- u?.name,
- u?.avatar?.large,
+ u.id,
+ u.name,
+ u.avatar?.large,
)
- if (setSettings) {
- setKey(accountId, ANILIST_USER_KEY, user)
- registerAccount()
- }
- /* // TODO FIX FAVS
- for(i in u.favourites.anime.nodes) {
- println("FFAV:" + i.id)
- }*/
return user
}
@@ -1048,8 +1007,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class AniListViewer(
- @JsonProperty("id") val id: Int?,
- @JsonProperty("name") val name: String?,
+ @JsonProperty("id") val id: Int,
+ @JsonProperty("name") val name: String,
@JsonProperty("avatar") val avatar: AniListAvatar?,
@JsonProperty("favourites") val favourites: AniListFavourites?,
)
@@ -1063,8 +1022,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class AniListUser(
- @JsonProperty("id") val id: Int?,
- @JsonProperty("name") val name: String?,
+ @JsonProperty("id") val id: Int,
+ @JsonProperty("name") val name: String,
@JsonProperty("picture") val picture: String?,
)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt
deleted file mode 100644
index 94537ea3367..00000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-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")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt
index 724d72163b0..29c3c0c1793 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt
@@ -1,8 +1,680 @@
package com.lagradost.cloudstream3.syncproviders.providers
+
+import androidx.annotation.StringRes
import com.fasterxml.jackson.annotation.JsonProperty
+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.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 okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import java.text.SimpleDateFormat
+import java.time.Instant
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+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()
+
+ return AuthToken(
+ accessTokenLifetime = 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()
+
+ return AuthToken(
+ accessToken = res.accessToken,
+ refreshToken = res.refreshToken,
+ accessTokenLifetime = 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()
+
+ 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? {
+ 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()
+
+ 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
+ }
+
+ data class KitsuResponse(
+ @field:JsonProperty(value = "data")
+ val data: KitsuNode,
+ )
+
+ val url =
+ "$apiUrl/anime/$id"
+
+ val anime = app.get(
+ url, headers = mapOf(
+ "Authorization" to "Bearer $auth"
+ ),
+ interceptor = apiFallbackInterceptor
+ ).parsed().data.attributes
+
+ return SyncResult(
+ id = id,
+ totalEpisodes = anime.episodeCount,
+ title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(),
+ publicScore = Score.from(anime.ratingTwenty.toString(), 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().data.firstOrNull()?.attributes
+
+ if (anime == null) {
+ return SyncStatus(
+ score = null,
+ status = SyncWatchType.NONE,
+ isFavorite = null,
+ watchedEpisodes = null
+ )
+ }
+
+ return SyncStatus(
+ score = Score.from(anime.ratingTwenty.toString(), 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()
+
+ 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().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()
+ }
+
+ 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? {
+ return if (requireLibraryRefresh) {
+ val list = getKitsuAnimeList(auth.token, auth.user.id)
+ setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list)
+ list
+ } else {
+ getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array
+ }
+ }
+
+ private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array {
+
+ val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","episodeCount")
+ val libraryEntriesSelectedFields = arrayOf("progress","rating","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()
+
+ 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 {
+ val res = app.get(
+ url, headers = mapOf(
+ "Authorization" to "Bearer ${token.accessToken}",
+ ),
+ interceptor = apiFallbackInterceptor
+ ).parsed()
+ return res
+ }
+
+
+ data class ResponseToken(
+ @JsonProperty("token_type") val tokenType: String,
+ @JsonProperty("expires_in") val expiresIn: Int,
+ @JsonProperty("access_token") val accessToken: String,
+ @JsonProperty("refresh_token") val refreshToken: String,
+ )
+
+ data class KitsuNode(
+ @JsonProperty("id") val id: String,
+ @JsonProperty("attributes") val attributes: KitsuNodeAttributes,
+ /* User list anime node */
+ @JsonProperty("relationships") val relationships: KitsuRelationships?,
+ 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.toString(), 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(
+ Instant.from(
+ DateTimeFormatter.ofPattern(if (startDate.length == 4) "yyyy" else if (startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd")
+ .parse(startDate)
+ )
+ )
+ } catch (_: RuntimeException) {
+ null
+ }
+ )
+ }
+
+ }
+
+ data class KitsuAnimeAttributes(
+ @JsonProperty("titles") val titles: KitsuTitles?,
+ @JsonProperty("canonicalTitle") val canonicalTitle: String?,
+ @JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
+ @JsonProperty("synopsis") val synopsis: String?,
+ @JsonProperty("startDate") val startDate: String?,
+ @JsonProperty("endDate") val endDate: String?,
+ @JsonProperty("episodeCount") val episodeCount: Int?,
+ @JsonProperty("episodeLength") val episodeLength: Int?,
+ )
+
+ data class KitsuAnimeData(
+ @JsonProperty("id") val id: String,
+ @JsonProperty("attributes") val attributes: KitsuAnimeAttributes,
+ )
+
+
+ data class KitsuNodeAttributes(
+ /* General attributes */
+ @JsonProperty("titles") val titles: KitsuTitles?,
+ @JsonProperty("canonicalTitle") val canonicalTitle: String?,
+ @JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
+ @JsonProperty("synopsis") val synopsis: String?,
+ @JsonProperty("startDate") val startDate: String?,
+ @JsonProperty("endDate") val endDate: String?,
+ @JsonProperty("episodeCount") val episodeCount: Int?,
+ @JsonProperty("episodeLength") val episodeLength: Int?,
+ /* User attributes */
+ @JsonProperty("name") val name: String?,
+ @JsonProperty("location") val location: String?,
+ @JsonProperty("createdAt") val createdAt: String?,
+ @JsonProperty("avatar") val avatar: KitsuUserAvatar?,
+ /* User list anime attributes */
+ @JsonProperty("progress") val progress: Int?,
+ @JsonProperty("ratingTwenty") val ratingTwenty: Float?,
+ @JsonProperty("updatedAt") val updatedAt: String?,
+ @JsonProperty("status") val status: String?,
+ )
+
+ data class KitsuRelationships(
+ @JsonProperty("anime") val anime: KitsuRelationshipsAnime?
+ )
+
+ data class KitsuRelationshipsAnime(
+ @JsonProperty("links") val links: KitsuLinks?
+ )
+
+ data class KitsuPosterImage(
+ @JsonProperty("large") val large: String?,
+ @JsonProperty("medium") val medium: String?,
+ )
+
+ data class KitsuTitles(
+ @JsonProperty("en_jp") val enJp: String?,
+ @JsonProperty("ja_jp") val jaJp: String?
+ )
+
+ data class KitsuUserAvatar(
+ @JsonProperty("original") val original: String?
+ )
+
+ data class KitsuLinks(
+ /* Pagination */
+ @JsonProperty("first") val first: String?,
+ @JsonProperty("next") val next: String?,
+ @JsonProperty("last") val last: String?,
+ /* Relationships */
+ @JsonProperty("related") val related: String?
+ )
+
+ data class KitsuResponse(
+ @JsonProperty("links") val links: KitsuLinks?,
+ @JsonProperty("data") val data: List,
+ /* When requesting related info (User library entry -> anime) */
+ @JsonProperty("included") val included: List?,
+ )
+
+
+ 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:ssZ", Locale.getDefault()).parse(
+ string ?: return null
+ )?.time?.div(1000)
+ } catch (e: 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
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
@@ -142,4 +814,4 @@ query {
val canonical: String? = null
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt
index 2b51f7efdf6..8f0d7ca6dac 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt
@@ -1,13 +1,11 @@
package com.lagradost.cloudstream3.syncproviders.providers
-import androidx.fragment.app.FragmentActivity
import com.lagradost.cloudstream3.R
-import com.lagradost.cloudstream3.syncproviders.AuthAPI
+import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.WatchType
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.isLayout
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
@@ -16,56 +14,19 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
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 idPrefix = "local"
+
override val icon: Int = R.drawable.ic_baseline_storage_24
override val requiresLogin = false
- override val supportDeviceAuth = false
- override val createAccountUrl: Nothing? = null
- override val idPrefix = "local"
+ override val createAccountUrl = null
override var requireLibraryRefresh = true
-
- 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? {
- return null
- }
-
- override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
+ override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? {
val watchStatusIds = ioWork {
getAllWatchStateIds()?.map { id ->
Pair(id, getResultWatchState(id))
@@ -102,9 +63,10 @@ class LocalList : SyncAPI {
val result = if (isTrueTv) {
baseMap + watchStatusMap + favoritesMap
} else {
- val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
- it.toLibraryItem()
- })
+ val subscriptionsMap =
+ mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
+ it.toLibraryItem()
+ })
baseMap + watchStatusMap + subscriptionsMap + favoritesMap
}
@@ -112,8 +74,8 @@ class LocalList : SyncAPI {
result
}
- return SyncAPI.LibraryMetadata(
- list.map { SyncAPI.LibraryList(txt(it.key), it.value) },
+ return LibraryMetadata(
+ list.map { LibraryList(txt(it.key), it.value) },
setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
@@ -127,8 +89,4 @@ class LocalList : SyncAPI {
)
)
}
-
- override fun getIdFromUrl(url: String): String {
- return url
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt
index 4836eca131d..ba0195be6b8 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt
@@ -1,87 +1,112 @@
package com.lagradost.cloudstream3.syncproviders.providers
-import android.util.Base64
import androidx.annotation.StringRes
-import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+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.mvvm.logError
-import com.lagradost.cloudstream3.syncproviders.AccountManager
-import com.lagradost.cloudstream3.syncproviders.AuthAPI
+import com.lagradost.cloudstream3.syncproviders.AuthData
+import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
+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.txt
-import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
-import java.net.URL
-import java.security.SecureRandom
-import java.text.ParseException
+import com.lagradost.cloudstream3.utils.txt
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.format.DateTimeFormatter
-import java.util.Calendar
import java.util.Date
import java.util.Locale
-import java.util.TimeZone
/** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
const val MAL_MAX_SEARCH_LIMIT = 25
-class MALApi(index: Int) : AccountManager(index), SyncAPI {
+class MALApi : SyncAPI() {
override var name = "MAL"
- override val key = "1714d6f2f4f7cc19644384f8c4629910"
- override val redirectUrl = "mallogin"
override val idPrefix = "mal"
- override var mainUrl = "https://myanimelist.net"
+
+ val key = "1714d6f2f4f7cc19644384f8c4629910"
private val apiUrl = "https://api.myanimelist.net"
+ override val hasOAuth2 = true
+ override val redirectUrlIdentifier: String? = "mallogin"
+ override val mainUrl = "https://myanimelist.net"
override val icon = R.drawable.mal_logo
- override val requiresLogin = false
- override val supportDeviceAuth = false
override val syncIdName = SyncIdName.MyAnimeList
- override var requireLibraryRefresh = true
override val createAccountUrl = "$mainUrl/register.php"
- override fun logOut() {
- requireLibraryRefresh = true
- removeAccountKeys()
- }
+ override val supportedWatchTypes = setOf(
+ SyncWatchType.WATCHING,
+ SyncWatchType.COMPLETED,
+ SyncWatchType.PLANTOWATCH,
+ SyncWatchType.DROPPED,
+ SyncWatchType.ONHOLD,
+ SyncWatchType.NONE
+ )
- override fun loginInfo(): AuthAPI.LoginInfo? {
- getKey(accountId, MAL_USER_KEY)?.let { user ->
- return AuthAPI.LoginInfo(
- profilePicture = user.picture,
- name = user.name,
- accountIndex = accountIndex
- )
+ data class PayLoad(
+ val requestId: Int,
+ val codeVerifier: String
+ )
+
+ override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
+ val payloadData = parseJson(payload!!)
+ val sanitizer = splitRedirectUrl(redirectUrl)
+ val state = sanitizer["state"]!!
+
+ if (state != "RequestID${payloadData.requestId}") {
+ return null
}
- return null
+
+ val currentCode = sanitizer["code"]!!
+
+ val token = app.post(
+ "$mainUrl/v1/oauth2/token",
+ data = mapOf(
+ "client_id" to key,
+ "code" to currentCode,
+ "code_verifier" to payloadData.codeVerifier,
+ "grant_type" to "authorization_code"
+ )
+ ).parsed()
+ return AuthToken(
+ accessTokenLifetime = unixTime + token.expiresIn.toLong(),
+ refreshToken = token.refreshToken,
+ accessToken = token.accessToken
+ )
}
- private fun getAuth(): String? {
- return getKey(
- accountId,
- MAL_TOKEN_KEY
+ override suspend fun user(token: AuthToken?): AuthUser? {
+ val user = app.get(
+ "$apiUrl/v2/users/@me",
+ headers = mapOf(
+ "Authorization" to "Bearer ${token?.accessToken ?: return null}"
+ ), cacheTime = 0
+ ).parsed()
+ return AuthUser(
+ id = user.id,
+ name = user.name,
+ profilePicture = user.picture
)
}
- override suspend fun search(name: String): List {
+ override suspend fun search(auth : AuthData?, query: String): List? {
+ val auth = auth?.token?.accessToken ?: return null
val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
- val auth = getAuth() ?: return emptyList()
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth",
), cacheTime = 0
- ).text
- return parseJson(res).data.map {
+ ).parsed()
+ return res.data.map {
val node = it.node
SyncAPI.SyncSearchResult(
node.title,
@@ -93,19 +118,21 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
}
- override fun getIdFromUrl(url: String): String {
- return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
- }
+ override fun urlToId(url: String): String? =
+ Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
- override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
+ override suspend fun updateStatus(
+ auth : AuthData?,
+ id: String,
+ newStatus: SyncAPI.AbstractSyncStatus
+ ): Boolean {
return setScoreRequest(
+ auth?.token ?: return false,
id.toIntOrNull() ?: return false,
- fromIntToAnimeStatus(status.status.internalId),
- status.score,
- status.watchedEpisodes
- ).also {
- requireLibraryRefresh = requireLibraryRefresh || it
- }
+ fromIntToAnimeStatus(newStatus.status),
+ newStatus.score?.toInt(10),
+ newStatus.watchedEpisodes
+ )
}
data class MalAnime(
@@ -198,14 +225,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- override suspend fun getResult(id: String): SyncAPI.SyncResult? {
+ override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? {
+ val auth = auth?.token?.accessToken ?: return null
val internalId = id.toIntOrNull() ?: return null
val url =
"$apiUrl/v2/anime/$internalId?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,related_manga,recommendations,studios,statistics"
- val auth = getAuth()
val res = app.get(
- url, headers = if (auth == null) emptyMap() else mapOf(
+ url, headers = mapOf(
"Authorization" to "Bearer $auth"
)
).text
@@ -214,7 +241,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
id = internalId.toString(),
totalEpisodes = malAnime.numEpisodes,
title = malAnime.title,
- publicScore = malAnime.mean?.toFloat()?.times(1000)?.toInt(),
+ publicScore = Score.from10(malAnime.mean),
duration = malAnime.averageEpisodeDuration,
synopsis = malAnime.synopsis,
airStatus = when (malAnime.status) {
@@ -244,13 +271,20 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
}
- override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
- val internalId = id.toIntOrNull() ?: return null
+ override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
+ val auth = auth?.token?.accessToken ?: return null
+
+ // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
+ val url =
+ "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status"
+ val data = app.get(
+ url, headers = mapOf(
+ "Authorization" to "Bearer $auth"
+ ), cacheTime = 0
+ ).parsed().myListStatus
- val data =
- getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status")
return SyncAPI.SyncStatus(
- score = data?.score,
+ score = Score.from10(data?.score),
status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
isFavorite = null,
watchedEpisodes = data?.numEpisodesWatched,
@@ -261,14 +295,17 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private val malStatusAsString =
arrayOf("watching", "completed", "on_hold", "dropped", "plan_to_watch")
- const val MAL_USER_KEY: String = "mal_user" // user data like profile
const val MAL_CACHED_LIST: String = "mal_cached_list"
- const val MAL_UNIXTIME_KEY: String = "mal_unixtime" // When token expires
- const val MAL_REFRESH_TOKEN_KEY: String = "mal_refresh_token" // refresh token
- const val MAL_TOKEN_KEY: String = "mal_token" // anilist token for api
fun convertToStatus(string: String): MalStatusType {
- return fromIntToAnimeStatus(malStatusAsString.indexOf(string))
+ return when (string) {
+ "watching" -> MalStatusType.Watching
+ "completed" -> MalStatusType.Completed
+ "on_hold" -> MalStatusType.OnHold
+ "dropped" -> MalStatusType.Dropped
+ "plan_to_watch" -> MalStatusType.PlanToWatch
+ else -> MalStatusType.None
+ }
}
enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) {
@@ -280,16 +317,15 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
None(-1, R.string.type_none)
}
- private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp }
+ private fun fromIntToAnimeStatus(inp: SyncWatchType): MalStatusType {//= AniListStatusType.values().first { it.value == inp }
return when (inp) {
- -1 -> MalStatusType.None
- 0 -> MalStatusType.Watching
- 1 -> MalStatusType.Completed
- 2 -> MalStatusType.OnHold
- 3 -> MalStatusType.Dropped
- 4 -> MalStatusType.PlanToWatch
- 5 -> MalStatusType.Watching
- else -> MalStatusType.None
+ SyncWatchType.NONE -> MalStatusType.None
+ SyncWatchType.WATCHING -> MalStatusType.Watching
+ SyncWatchType.COMPLETED -> MalStatusType.Completed
+ SyncWatchType.ONHOLD -> MalStatusType.OnHold
+ SyncWatchType.DROPPED -> MalStatusType.Dropped
+ SyncWatchType.PLANTOWATCH -> MalStatusType.PlanToWatch
+ SyncWatchType.REWATCHING -> MalStatusType.Watching
}
}
@@ -304,86 +340,39 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
}
- override suspend fun handleRedirect(url: String): Boolean {
- val sanitizer =
- splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
- val state = sanitizer["state"]!!
- if (state == "RequestID$requestId") {
- val currentCode = sanitizer["code"]!!
-
- val res = app.post(
- "$mainUrl/v1/oauth2/token",
- data = mapOf(
- "client_id" to key,
- "code" to currentCode,
- "code_verifier" to codeVerifier,
- "grant_type" to "authorization_code"
- )
- ).text
-
- if (res.isNotBlank()) {
- switchToNewAccount()
- storeToken(res)
- val user = getMalUser()
- requireLibraryRefresh = true
- return user != null
- }
- }
- return false
- }
-
- override fun authenticate(activity: FragmentActivity?) {
- // 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)
- codeVerifier =
- Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=').replace("+", "-")
- .replace("/", "_").replace("\n", "")
+ override fun loginRequest(): AuthLoginPage? {
+ val codeVerifier = generateCodeVerifier()
+ val requestId = ++requestIdCounter
val codeChallenge = codeVerifier
val request =
"$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId"
- openBrowser(request, activity)
- }
- private var requestId = 0
- private var codeVerifier = ""
-
- private fun storeToken(response: String) {
- try {
- if (response != "") {
- val token = parseJson(response)
- setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime))
- setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken)
- setKey(accountId, MAL_TOKEN_KEY, token.accessToken)
- requireLibraryRefresh = true
- }
- } catch (e: Exception) {
- logError(e)
- }
+ return AuthLoginPage(
+ url = request,
+ payload = PayLoad(requestId, codeVerifier).toJson()
+ )
}
- private suspend fun refreshToken() {
- try {
- val res = app.post(
- "$mainUrl/v1/oauth2/token",
- data = mapOf(
- "client_id" to key,
- "grant_type" to "refresh_token",
- "refresh_token" to getKey(
- accountId,
- MAL_REFRESH_TOKEN_KEY
- )!!
- )
- ).text
- storeToken(res)
- } catch (e: Exception) {
- logError(e)
- }
+ override suspend fun refreshToken(token: AuthToken): AuthToken? {
+ val res = app.post(
+ "$mainUrl/v1/oauth2/token",
+ data = mapOf(
+ "client_id" to key,
+ "grant_type" to "refresh_token",
+ "refresh_token" to token.refreshToken!!
+ )
+ ).parsed()
+
+ return AuthToken(
+ accessToken = res.accessToken,
+ refreshToken = res.refreshToken,
+ accessTokenLifetime = unixTime + res.expiresIn.toLong()
+ )
}
+ private var requestIdCounter = 0
+
+
private val allTitles = hashMapOf()
data class MalList(
@@ -441,7 +430,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
this.node.id.toString(),
this.listStatus?.numEpisodesWatched,
this.node.numEpisodes,
- this.listStatus?.score?.times(10),
+ Score.from10(this.listStatus?.score),
parseDateLong(this.listStatus?.updatedAt),
"MAL",
TvType.Anime,
@@ -449,12 +438,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
null,
null,
plot = this.node.synopsis,
- releaseDate = if (this.node.startDate == null) null else try {Date.from(
- Instant.from(
- DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd")
- .parse(this.node.startDate)
+ releaseDate = if (this.node.startDate == null) null else try {
+ Date.from(
+ Instant.from(
+ DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd")
+ .parse(this.node.startDate)
+ )
)
- )} catch (_: RuntimeException) {null}
+ } catch (_: RuntimeException) {
+ null
+ }
)
}
}
@@ -484,23 +477,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("start_time") val startTime: String?
)
- private fun getMalAnimeListCached(): Array? {
- return getKey(MAL_CACHED_LIST) as? Array
- }
-
- private suspend fun getMalAnimeListSmart(): Array? {
- if (getAuth() == null) return null
- return if (requireLibraryRefresh) {
- val list = getMalAnimeList()
- setKey(MAL_CACHED_LIST, list)
- list
- } else {
- getMalAnimeListCached()
- }
- }
-
- override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
- val list = getMalAnimeListSmart()?.groupBy {
+ override suspend fun library(auth : AuthData?): LibraryMetadata? {
+ val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
convertToStatus(it.listStatus?.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
@@ -527,13 +505,22 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- private suspend fun getMalAnimeList(): Array {
- checkMalToken()
+ private suspend fun getMalAnimeListSmart(auth : AuthData): Array? {
+ return if (requireLibraryRefresh) {
+ val list = getMalAnimeList(auth.token)
+ setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)
+ list
+ } else {
+ getKey>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array
+ }
+ }
+
+ private suspend fun getMalAnimeList(token: AuthToken): Array {
var offset = 0
val fullList = mutableListOf()
val offsetRegex = Regex("""offset=(\d+)""")
while (true) {
- val data: MalList = getMalAnimeListSlice(offset) ?: break
+ val data: MalList = getMalAnimeListSlice(token, offset) ?: break
fullList.addAll(data.data)
offset =
data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() }
@@ -542,128 +529,29 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
return fullList.toTypedArray()
}
- private suspend fun getMalAnimeListSlice(offset: Int = 0): MalList? {
+ private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList? {
val user = "@me"
- val auth = getAuth() ?: return null
// Very lackluster docs
// https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get
val url =
"$apiUrl/v2/users/$user/animelist?fields=list_status,num_episodes,media_type,status,start_date,end_date,synopsis,alternative_titles,mean,genres,rank,num_list_users,nsfw,average_episode_duration,num_favorites,popularity,num_scoring_users,start_season,favorites_info,broadcast,created_at,updated_at&nsfw=1&limit=100&offset=$offset"
val res = app.get(
url, headers = mapOf(
- "Authorization" to "Bearer $auth",
+ "Authorization" to "Bearer ${token.accessToken}",
), cacheTime = 0
).text
return res.toKotlinObject()
}
- private suspend fun getDataAboutMalId(id: Int): SmallMalAnime? {
- // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
- val url =
- "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status"
- val res = app.get(
- url, headers = mapOf(
- "Authorization" to "Bearer " + (getAuth() ?: return null)
- ), cacheTime = 0
- ).text
-
- return parseJson(res)
- }
-
- suspend fun setAllMalData() {
- val user = "@me"
- var isDone = false
- var index = 0
- allTitles.clear()
- checkMalToken()
- while (!isDone) {
- val res = app.get(
- "$apiUrl/v2/users/$user/animelist?fields=list_status&limit=1000&offset=${index * 1000}",
- headers = mapOf(
- "Authorization" to "Bearer " + (getAuth() ?: return)
- ), cacheTime = 0
- ).text
- val values = parseJson(res)
- val titles =
- values.data.map { MalTitleHolder(it.listStatus, it.node.id, it.node.title) }
- for (t in titles) {
- allTitles[t.id] = t
- }
- isDone = titles.size < 1000
- index++
- }
- }
-
- private fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? {
- // No time remaining if the show has already ended
- try {
- endDate?.let {
- if (SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it)
- ?.before(Date.from(Instant.now())) != false
- ) return@convertJapanTimeToTimeRemaining null
- }
- } catch (e: ParseException) {
- logError(e)
- }
-
- // Unparseable date: "2021 7 4 other null"
- // Weekday: other, date: null
- if (date.contains("null") || date.contains("other")) {
- return null
- }
-
- val currentDate = Calendar.getInstance()
- val currentMonth = currentDate.get(Calendar.MONTH) + 1
- val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH)
- val currentYear = currentDate.get(Calendar.YEAR)
-
- val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm", Locale.getDefault())
- dateFormat.timeZone = TimeZone.getTimeZone("Japan")
- val parsedDate =
- dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null
- val timeDiff = (parsedDate.time - System.currentTimeMillis()) / 1000
-
- // if it has already aired this week add a week to the timer
- val updatedTimeDiff =
- if (timeDiff > -60 * 60 * 24 * 7 && timeDiff < 0) timeDiff + 60 * 60 * 24 * 7 else timeDiff
- return secondsToReadable(updatedTimeDiff.toInt(), "Now")
-
- }
-
- private suspend fun checkMalToken() {
- if (unixTime > (getKey(
- accountId,
- MAL_UNIXTIME_KEY
- ) ?: 0L)
- ) {
- refreshToken()
- }
- }
-
- private suspend fun getMalUser(setSettings: Boolean = true): MalUser? {
- checkMalToken()
- val res = app.get(
- "$apiUrl/v2/users/@me",
- headers = mapOf(
- "Authorization" to "Bearer " + (getAuth() ?: return null)
- ), cacheTime = 0
- ).text
-
- val user = parseJson(res)
- if (setSettings) {
- setKey(accountId, MAL_USER_KEY, user)
- registerAccount()
- }
- return user
- }
-
private suspend fun setScoreRequest(
+ token: AuthToken,
id: Int,
status: MalStatusType? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val res = setScoreRequest(
+ token,
id,
if (status == null) null else malStatusAsString[maxOf(0, status.value)],
score,
@@ -686,6 +574,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
@Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest(
+ token: AuthToken,
id: Int,
status: String? = null,
score: Int? = null,
@@ -700,7 +589,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
return app.put(
"$apiUrl/v2/anime/$id/my_list_status",
headers = mapOf(
- "Authorization" to "Bearer " + (getAuth() ?: return null)
+ "Authorization" to "Bearer ${token.accessToken}"
),
data = data
).text
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt
index 2e28f19455b..4b17fdb2920 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt
@@ -2,56 +2,44 @@ package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
-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.app
import com.lagradost.cloudstream3.ErrorLoadingException
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.syncproviders.AuthAPI
-import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
-import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
+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.utils.AppUtils
-import okhttp3.Interceptor
-import okhttp3.Response
+import com.lagradost.cloudstream3.utils.AppUtils.parseJson
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
+import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag
-class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
- override val idPrefix = "opensubtitles"
+class OpenSubtitlesApi : SubtitleAPI() {
override val name = "OpenSubtitles"
+ override val idPrefix = "opensubtitles"
+
override val icon = R.drawable.open_subtitles_icon
- override val requiresPassword = true
- override val requiresUsername = true
+ override val hasInApp = true
+ override val inAppLoginRequirement = AuthLoginRequirement(
+ password = true,
+ username = true,
+ )
+
override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up"
companion object {
- const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile
const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
const val HOST = "https://api.opensubtitles.com/api/v1"
const val TAG = "OPENSUBS"
const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms
var currentCoolDown: Long = 0L
- var currentSession: SubtitleOAuthEntity? = null
- }
-
- 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()
- )
- }
+ const val userAgent = "Cloudstream3 v0.2"
+ val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY)
}
private fun canDoRequest(): Boolean {
@@ -69,121 +57,53 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
throw ErrorLoadingException("Too many requests")
}
- private fun getAuthKey(): SubtitleOAuthEntity? {
- return getKey(accountId, OPEN_SUBTITLES_USER_KEY)
- }
-
- private fun setAuthKey(data: SubtitleOAuthEntity?) {
- if (data == null) removeKey(accountId, OPEN_SUBTITLES_USER_KEY)
- currentSession = data
- setKey(accountId, OPEN_SUBTITLES_USER_KEY, data)
+ override suspend fun refreshToken(token: AuthToken): AuthToken? {
+ return login(parseJson(token.payload ?: return null))
}
- override fun loginInfo(): AuthAPI.LoginInfo? {
- getAuthKey()?.let { user ->
- 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)
+ override suspend fun user(token: AuthToken?): AuthUser? {
+ val user = parseJson(token?.payload ?: return null)
+ val username = user.username ?: return null
+ return AuthUser(
+ id = username.hashCode(),
+ name = username
+ )
}
- /*
- 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 suspend fun login(form: AuthLoginResponse): AuthToken? {
+ val username = form.username ?: return null
+ val password = form.password ?: return null
- 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(
url = "$HOST/login",
headers = mapOf(
"Content-Type" to "application/json",
- ),
+ ) + headers,
json = mapOf(
"username" to username,
"password" to password
),
- interceptor = headerInterceptor
+ ).parsed()
+
+ return AuthToken(
+ accessToken = response.token
+ ?: throw ErrorLoadingException("Invalid password or username"),
+ /// JWT token is valid 24 hours after successfully authentication of user
+ accessTokenLifetime = unixTime + 60 * 60 * 24,
+ payload = form.toJson()
)
- //Log.i(TAG, "Responsecode = ${response.code}")
- //Log.i(TAG, "Result => ${response.text}")
-
- if (response.isSuccessful) {
- AppUtils.tryParseJson(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(
-// "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).
* Returns list of Subtitles which user can select to download (see load).
* */
- override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? {
+ override suspend fun search(
+ auth : AuthData?,
+ query: AbstractSubtitleEntities.SubtitleSearch
+ ): List? {
throwIfCantDoRequest()
- val fixedLang = fixLanguage(query.lang)
+ val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: ""
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
val queryText = query.query
@@ -196,17 +116,17 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
val searchQueryUrl = when (imdbId > 0) {
//Use imdb_id to search if its valid
- true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
- false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
+ true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
+ false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
}
val req = app.get(
url = searchQueryUrl,
headers = mapOf(
Pair("Content-Type", "application/json")
- ),
- interceptor = headerInterceptor
+ ) + headers,
)
+ Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}")
Log.i(TAG, "Search Req => ${req.text}")
if (!req.isSuccessful) {
if (req.code == 429)
@@ -227,7 +147,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
//Use any valid name/title in hierarchy
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
?: featureDetails?.parentTitle ?: attr.release ?: query.query
- val lang = fixLanguageReverse(attr.language) ?: ""
+ val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: ""
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
val year = featureDetails?.year ?: query.year
@@ -241,7 +161,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix,
name = name,
- lang = lang,
+ lang = langTagIETF,
data = resultData,
type = type,
source = this.name,
@@ -261,7 +181,12 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
Process data returned from search.
Returns string url for the subtitle file.
*/
- override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? {
+
+ override suspend fun load(
+ auth : AuthData?,
+ subtitle: AbstractSubtitleEntities.SubtitleEntity
+ ): String? {
+ if(auth == null) return null
throwIfCantDoRequest()
val req = app.post(
@@ -269,15 +194,14 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
headers = mapOf(
Pair(
"Authorization",
- "Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
+ "Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
),
Pair("Content-Type", "application/json"),
Pair("Accept", "*/*")
- ),
+ ) + headers,
data = mapOf(
- Pair("file_id", data.data)
- ),
- interceptor = headerInterceptor
+ Pair("file_id", subtitle.data)
+ )
)
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
//Log.i(TAG, "Request headers => ${req.headers}")
@@ -294,13 +218,6 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
return null
}
-
- data class SubtitleOAuthEntity(
- var user: String,
- var pass: String,
- var accessToken: String,
- )
-
data class OAuthToken(
@JsonProperty("token") var token: String? = null,
@JsonProperty("status") var status: Int? = null
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt
index 519fb4c3a28..c4095e2d881 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt
@@ -2,38 +2,36 @@ package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes
import androidx.core.net.toUri
-import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.AcraApplication
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
-import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
-import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.BuildConfig
+import com.lagradost.cloudstream3.CloudStreamApp
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SimklSyncServices
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mapper
-import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.mvvm.safeAsync
-import com.lagradost.cloudstream3.syncproviders.AccountManager
-import com.lagradost.cloudstream3.syncproviders.AuthAPI
-import com.lagradost.cloudstream3.syncproviders.OAuth2API
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
+import com.lagradost.cloudstream3.syncproviders.AuthData
+import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
+import com.lagradost.cloudstream3.syncproviders.AuthPinData
+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.txt
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
-import okhttp3.Interceptor
-import okhttp3.Response
+import com.lagradost.cloudstream3.utils.txt
import java.math.BigInteger
import java.security.SecureRandom
import java.text.SimpleDateFormat
@@ -45,25 +43,22 @@ import kotlin.time.Duration
import kotlin.time.DurationUnit
import kotlin.time.toDuration
-class SimklApi(index: Int) : AccountManager(index), SyncAPI {
+class SimklApi : SyncAPI() {
override var name = "Simkl"
- override val key = "simkl-key"
- override val redirectUrl = "simkl"
- override val supportDeviceAuth = true
override val idPrefix = "simkl"
+
+ val key = "simkl-key"
+ override val redirectUrlIdentifier = "simkl"
+ override val hasOAuth2 = true
+ override val hasPin = true
override var requireLibraryRefresh = true
override var mainUrl = "https://api.simkl.com"
override val icon = R.drawable.simkl_logo
- override val requiresLogin = false
override val createAccountUrl = "$mainUrl/signup"
override val syncIdName = SyncIdName.Simkl
- private val token: String?
- get() = getKey(accountId, SIMKL_TOKEN_KEY).also {
- debugAssert({ it == null }) { "No ${this.name} token!" }
- }
/** Automatically adds simkl auth headers */
- private val interceptor = HeaderInterceptor()
+ // private val interceptor = HeaderInterceptor()
/**
* This is required to override the reported last activity as simkl activites
@@ -101,7 +96,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
fun cleanOldCache() {
getKeys(SIMKL_CACHE_KEY)?.forEach {
- val isOld = AcraApplication.getKey>(it)?.isFresh() == false
+ val isOld = CloudStreamApp.getKey>(it)?.isFresh() == false
if (isOld) {
removeKey(it)
}
@@ -148,10 +143,6 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
companion object {
private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID
private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET
- private var lastLoginState = ""
-
- const val SIMKL_TOKEN_KEY: String = "simkl_token"
- const val SIMKL_USER_KEY: String = "simkl_user"
const val SIMKL_CACHED_LIST: String = "simkl_cached_list"
const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time"
@@ -237,13 +228,23 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
/** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */
data class SettingsResponse(
- val user: User
+ @JsonProperty("user")
+ val user: User,
+ @JsonProperty("account")
+ val account: Account,
) {
data class User(
+ @JsonProperty("name")
val name: String,
/** Url */
+ @JsonProperty("avatar")
val avatar: String
)
+
+ data class Account(
+ @JsonProperty("id")
+ val id: Int,
+ )
}
data class PinAuthResponse(
@@ -365,7 +366,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
class SimklScoreBuilder private constructor() {
data class Builder(
private var url: String? = null,
- private var interceptor: Interceptor? = null,
+ private var headers: Map? = null,
private var ids: MediaObject.Ids? = null,
private var score: Int? = null,
private var status: Int? = null,
@@ -374,7 +375,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
// Required for knowing if the status should be overwritten
private var onList: Boolean = false
) {
- fun interceptor(interceptor: Interceptor) = apply { this.interceptor = interceptor }
+ fun token(token: AuthToken) = apply { this.headers = getHeaders(token) }
fun apiUrl(url: String) = apply { this.url = url }
fun ids(ids: MediaObject.Ids) = apply { this.ids = ids }
fun score(score: Int?, oldScore: Int?) = apply {
@@ -423,7 +424,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
suspend fun execute(): Boolean {
val time = getDateTime(unixTime)
-
+ val headers = this.headers ?: emptyMap()
return if (this.status == SimklListStatusType.None.value) {
app.post(
"$url/sync/history/remove",
@@ -431,7 +432,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
shows = listOf(HistoryMediaObject(ids = ids)),
movies = emptyList()
),
- interceptor = interceptor
+ headers = headers
).isSuccessful
} else {
val statusResponse = this.status?.let { setStatus ->
@@ -452,7 +453,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
)
), movies = emptyList()
),
- interceptor = interceptor
+ headers = headers
).isSuccessful
} ?: true
@@ -469,7 +470,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
),
movies = emptyList()
),
- interceptor = interceptor
+ headers = headers
).isSuccessful
} ?: true
@@ -496,7 +497,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
)
), movies = emptyList()
),
- interceptor = interceptor
+ headers = headers
).isSuccessful
} else {
true
@@ -508,6 +509,9 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
}
}
+ fun getHeaders(token: AuthToken): Map =
+ mapOf("Authorization" to "Bearer ${token.accessToken}", "simkl-api-key" to CLIENT_ID)
+
suspend fun getEpisodes(
simklId: Int?,
type: String?,
@@ -664,7 +668,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
movie.ids.simkl.toString(),
this.watchedEpisodesCount,
this.totalEpisodesCount,
- this.userRating?.times(10),
+ Score.from10(this.userRating),
getUnixTime(lastWatchedAt) ?: 0,
"Simkl",
TvType.Movie,
@@ -697,7 +701,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
show.ids.simkl.toString(),
this.watchedEpisodesCount,
this.totalEpisodesCount,
- this.userRating?.times(10),
+ Score.from10(this.userRating),
getUnixTime(lastWatchedAt) ?: 0,
"Simkl",
TvType.Anime,
@@ -746,7 +750,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
/**
* Appends api keys to the requests
**/
- private inner class HeaderInterceptor : Interceptor {
+ /*private inner class HeaderInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" }
return chain.proceed(
@@ -757,14 +761,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
.build()
)
}
- }
+ }*/
+
+ private suspend fun getUser(token: AuthToken): SettingsResponse =
+ app.post("$mainUrl/users/settings", headers = getHeaders(token))
+ .parsed()
- private suspend fun getUser(): SettingsResponse.User? {
- return safeAsync {
- app.post("$mainUrl/users/settings", interceptor = interceptor)
- .parsedSafe()?.user
- }
- }
/**
* Useful to get episodes on demand to prevent unnecessary requests.
@@ -782,7 +784,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
class SimklSyncStatus(
override var status: SyncWatchType,
- override var score: Int?,
+ override var score: Score?,
val oldScore: Int?,
override var watchedEpisodes: Int?,
val episodeConstructor: SimklEpisodeConstructor,
@@ -794,7 +796,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
val oldStatus: String?
) : SyncAPI.AbstractSyncStatus()
- override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
+ override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
+ if (auth == null) return null
val realIds = readIdFromString(id)
// Key which assumes all ids are the same each time :/
@@ -818,7 +821,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
searchResult.hasEnded()
)
- val foundItem = getSyncListSmart()?.let { list ->
+ val foundItem = getSyncListSmart(auth)?.let { list ->
listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show ->
realIds.any { (database, id) ->
show.getIds().matchesId(database, id)
@@ -836,7 +839,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
)
}
?: return null,
- score = foundItem.userRating,
+ score = Score.from10(foundItem.userRating),
watchedEpisodes = foundItem.watchedEpisodesCount,
maxEpisodes = searchResult.totalEpisodes,
episodeConstructor = episodeConstructor,
@@ -847,7 +850,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
} else {
return SimklSyncStatus(
status = SyncWatchType.fromInternalId(SimklListStatusType.None.value),
- score = 0,
+ score = null,
watchedEpisodes = 0,
maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes,
episodeConstructor = episodeConstructor,
@@ -858,22 +861,26 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
}
}
- override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
+ override suspend fun updateStatus(
+ auth: AuthData?,
+ id: String,
+ newStatus: AbstractSyncStatus
+ ): Boolean {
val parsedId = readIdFromString(id)
lastScoreTime = unixTime
- val simklStatus = status as? SimklSyncStatus
+ val simklStatus = newStatus as? SimklSyncStatus
val builder = SimklScoreBuilder.Builder()
.apiUrl(this.mainUrl)
- .score(status.score, simklStatus?.oldScore)
+ .score(newStatus.score?.toInt(10), simklStatus?.oldScore)
.status(
- status.status.internalId,
- (status as? SimklSyncStatus)?.oldStatus?.let { oldStatus ->
+ newStatus.status.internalId,
+ (newStatus as? SimklSyncStatus)?.oldStatus?.let { oldStatus ->
SimklListStatusType.entries.firstOrNull {
it.originalName == oldStatus
}?.value
})
- .interceptor(interceptor)
+ .token(auth?.token ?: return false)
.ids(MediaObject.Ids.fromMap(parsedId))
@@ -881,11 +888,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
val episodes = simklStatus?.episodeConstructor?.getEpisodes()
// All episodes if marked as completed
- val watchedEpisodes = if (status.status.internalId == SimklListStatusType.Completed.value) {
- episodes?.size
- } else {
- status.watchedEpisodes
- }
+ val watchedEpisodes =
+ if (newStatus.status.internalId == SimklListStatusType.Completed.value) {
+ episodes?.size
+ } else {
+ newStatus.watchedEpisodes
+ }
builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes)
@@ -906,39 +914,26 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
).parsedSafe()
}
- override suspend fun search(name: String): List? {
+ override suspend fun search(auth: AuthData?, query: String): List? {
return app.get(
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name)
).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() }
}
- override fun authenticate(activity: FragmentActivity?) {
- lastLoginState = BigInteger(130, SecureRandom()).toString(32)
+ override fun loginRequest(): AuthLoginPage? {
+ val lastLoginState = BigInteger(130, SecureRandom()).toString(32)
val url =
- "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState"
- openBrowser(url, activity)
- }
-
- override fun loginInfo(): AuthAPI.LoginInfo? {
- return getKey(accountId, SIMKL_USER_KEY)?.let { user ->
- AuthAPI.LoginInfo(
- name = user.name,
- profilePicture = user.avatar,
- accountIndex = accountIndex
- )
- }
- }
+ "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}&state=$lastLoginState"
- override fun logOut() {
- requireLibraryRefresh = true
- removeAccountKeys()
+ return AuthLoginPage(
+ url = url,
+ payload = lastLoginState
+ )
}
- override suspend fun getResult(id: String): SyncAPI.SyncResult? {
- return null
- }
+ override suspend fun load(auth: AuthData?, id: String): SyncResult? = null
- private suspend fun getSyncListSince(since: Long?): AllItemsResponse? {
+ private suspend fun getSyncListSince(auth: AuthData, since: Long?): AllItemsResponse? {
val params = getDateTime(since)?.let {
mapOf("date_from" to it)
} ?: emptyMap()
@@ -947,23 +942,22 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
return app.get(
"$mainUrl/sync/all-items/",
params = params,
- interceptor = interceptor
+ headers = getHeaders(auth.token)
).parsedSafe()
}
- private suspend fun getActivities(): ActivitiesResponse? {
- return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe()
+ private suspend fun getActivities(token: AuthToken): ActivitiesResponse? {
+ return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe()
}
- private fun getSyncListCached(): AllItemsResponse? {
- return getKey(accountId, SIMKL_CACHED_LIST)
+ private fun getSyncListCached(auth: AuthData): AllItemsResponse? {
+ return getKey(SIMKL_CACHED_LIST, auth.user.id.toString())
}
- private suspend fun getSyncListSmart(): AllItemsResponse? {
- if (token == null) return null
-
- val activities = getActivities()
- val lastCacheUpdate = getKey(accountId, SIMKL_CACHED_LIST_TIME)
+ private suspend fun getSyncListSmart(auth: AuthData): AllItemsResponse? {
+ val activities = getActivities(auth.token)
+ val userId = auth.user.id.toString()
+ val lastCacheUpdate = getKey(SIMKL_CACHED_LIST_TIME, auth.user.id.toString())
val lastRemoval = listOf(
activities?.tvShows?.removedFromList,
activities?.anime?.removedFromList,
@@ -983,26 +977,28 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" }
val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) {
debugPrint { "Full list update in ${this.name}." }
- setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval)
- getSyncListSince(null)
+ setKey(SIMKL_CACHED_LIST_TIME, userId, lastRemoval)
+ getSyncListSince(auth, null)
} else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) {
debugPrint { "Partial list update in ${this.name}." }
- setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate)
- AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate))
+ setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate)
+ AllItemsResponse.merge(
+ getSyncListCached(auth),
+ getSyncListSince(auth, lastCacheUpdate)
+ )
} else {
debugPrint { "Cached list update in ${this.name}." }
- getSyncListCached()
+ getSyncListCached(auth)
}
debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" }
- setKey(accountId, SIMKL_CACHED_LIST, list)
+ setKey(SIMKL_CACHED_LIST, userId, list)
return list
}
-
- override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
- val list = getSyncListSmart() ?: return null
+ override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
+ val list = getSyncListSmart(auth ?: return null) ?: return null
val baseMap =
SimklListStatusType.entries
@@ -1038,17 +1034,17 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- override fun getIdFromUrl(url: String): String {
+ override fun urlToId(url: String): String? {
val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""")
return simklUrlRegex.find(url)?.groupValues?.get(1) ?: ""
}
- override suspend fun getDevicePin(): OAuth2API.PinAuthData? {
+ override suspend fun pinRequest(): AuthPinData? {
val pinAuthResp = app.get(
- "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}"
+ "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}"
).parsedSafe() ?: return null
- return OAuth2API.PinAuthData(
+ return AuthPinData(
deviceCode = pinAuthResp.deviceCode,
userCode = pinAuthResp.userCode,
verificationUrl = pinAuthResp.verificationUrl,
@@ -1057,56 +1053,38 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean {
+ override suspend fun login(payload: AuthPinData): AuthToken? {
val pinAuthResp = app.get(
- "$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID"
- ).parsedSafe() ?: return false
-
- if (pinAuthResp.accessToken != null) {
- switchToNewAccount()
- setKey(accountId, SIMKL_TOKEN_KEY, pinAuthResp.accessToken)
-
- val user = getUser()
- if (user == null) {
- removeKey(accountId, SIMKL_TOKEN_KEY)
- switchToOldAccount()
- return false
- }
+ "$mainUrl/oauth/pin/${payload.userCode}?client_id=$CLIENT_ID"
+ ).parsedSafe() ?: return null
- setKey(accountId, SIMKL_USER_KEY, user)
- registerAccount()
- requireLibraryRefresh = true
- return true
- }
- return false
+ return AuthToken(
+ accessToken = pinAuthResp.accessToken ?: return null,
+ )
}
- override suspend fun handleRedirect(url: String): Boolean {
- val uri = url.toUri()
+ override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
+ val uri = redirectUrl.toUri()
val state = uri.getQueryParameter("state")
// Ensure consistent state
- if (state != lastLoginState) return false
- lastLoginState = ""
+ if (state != payload) return null
- val code = uri.getQueryParameter("code") ?: return false
- val token = app.post(
+ val code = uri.getQueryParameter("code") ?: return null
+ val tokenResponse = app.post(
"$mainUrl/oauth/token", json = TokenRequest(code)
- ).parsedSafe() ?: return false
-
- switchToNewAccount()
- setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken)
-
- val user = getUser()
- if (user == null) {
- removeKey(accountId, SIMKL_TOKEN_KEY)
- switchToOldAccount()
- return false
- }
+ ).parsedSafe() ?: return null
- setKey(accountId, SIMKL_USER_KEY, user)
- registerAccount()
- requireLibraryRefresh = true
+ return AuthToken(
+ accessToken = tokenResponse.accessToken,
+ )
+ }
- return true
+ override suspend fun user(token: AuthToken?): AuthUser? {
+ val user = getUser(token ?: return null)
+ return AuthUser(
+ id = user.account.id,
+ name = user.user.name,
+ profilePicture = user.user.avatar
+ )
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt
index 8dad1f88cfe..19122768e23 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt
@@ -3,27 +3,33 @@ package com.lagradost.cloudstream3.syncproviders.providers
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.subtitles.AbstractSubProvider
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource
+import com.lagradost.cloudstream3.syncproviders.AuthData
+import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.SubtitleHelper
-class SubSourceApi : AbstractSubProvider {
+class SubSourceApi : SubtitleAPI() {
+ override val name = "SubSource"
override val idPrefix = "subsource"
- val name = "SubSource"
+
+ override val requiresLogin = false
companion object {
const val APIURL = "https://api.subsource.net/api"
const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
}
- override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? {
+ override suspend fun search(
+ auth: AuthData?,
+ query: AbstractSubtitleEntities.SubtitleSearch
+ ): List? {
//Only supports Imdb Id search for now
if (query.imdbId == null) return null
- val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
+ val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang)
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
val searchRes = app.post(
@@ -87,15 +93,17 @@ class SubSourceApi : AbstractSubProvider {
}
}
- override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
-
- val parsedSub = parseJson(data.data)
+ override suspend fun SubtitleResource.getResources(
+ auth: AuthData?,
+ subtitle: AbstractSubtitleEntities.SubtitleEntity
+ ) {
+ val parsedSub = parseJson(subtitle.data)
val subRes = app.post(
url = "$APIURL/getSub",
data = mapOf(
"movie" to parsedSub.movie,
- "lang" to data.lang,
+ "lang" to subtitle.lang,
"id" to parsedSub.id
)
).parsedSafe() ?: return
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt
index 9cec1e1cac4..1f1e6de4450 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt
@@ -1,89 +1,71 @@
package com.lagradost.cloudstream3.syncproviders.providers
import com.fasterxml.jackson.annotation.JsonProperty
-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.TvType
import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.subtitles.AbstractSubApi
+import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource
-import com.lagradost.cloudstream3.syncproviders.AuthAPI.LoginInfo
-import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
-import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
+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
-class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
- override val idPrefix = "subdl"
+class SubDlApi : SubtitleAPI() {
override val name = "SubDL"
+ override val idPrefix = "subdl"
+
override val icon = R.drawable.subdl_logo_big
- override val requiresPassword = true
- override val requiresEmail = true
+ override val hasInApp = true
+ override val inAppLoginRequirement = AuthLoginRequirement(password = true, email = true)
+ override val requiresLogin = true
override val createAccountUrl = "https://subdl.com/panel/register"
companion object {
const val APIURL = "https://apiold.subdl.com"
const val APIENDPOINT = "$APIURL/api/v1/subtitles"
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
- const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user"
- var currentSession: SubtitleOAuthEntity? = null
}
- override suspend fun initialize() {
- currentSession = getAuthKey()
- }
-
- override fun logOut() {
- setAuthKey(null)
- removeAccountKeys()
- currentSession = getAuthKey()
- }
+ override suspend fun login(form: AuthLoginResponse): AuthToken? {
+ val email = form.email ?: return null
+ val password = form.password ?: return null
+ val tokenResponse = app.post(
+ url = "$APIURL/login",
+ json = mapOf(
+ "email" to email,
+ "password" to password
+ )
+ ).parsed()
- 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
- }
+ val apiResponse = app.get(
+ url = "$APIURL/user/userApi",
+ headers = mapOf(
+ "Authorization" to "Bearer ${tokenResponse.token}"
+ )
+ ).parsed()
- override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
- val current = getAuthKey() ?: return null
- return InAppAuthAPI.LoginData(
- email = current.userEmail,
- password = current.pass
- )
+ return AuthToken(accessToken = apiResponse.apiKey, payload = email)
}
- override fun loginInfo(): LoginInfo? {
- getAuthKey()?.let { user ->
- return LoginInfo(
- profilePicture = null,
- name = user.name ?: user.userEmail,
- accountIndex = accountIndex
- )
- }
- return null
+ override suspend fun user(token: AuthToken?): AuthUser? {
+ val name = token?.payload ?: return null
+ return AuthUser(id = name.hashCode(), name = name)
}
- override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? {
-
+ override suspend fun search(
+ auth : AuthData?,
+ query: AbstractSubtitleEntities.SubtitleSearch
+ ): List