Compare commits

..

5 commits

Author SHA1 Message Date
Cloudburst
1be78df977
Merge branch 'master' into action-api 2024-09-22 20:56:15 +02:00
Cloudburst
e288c83c3d remove unused strings, address comment 2024-09-17 23:37:59 +02:00
Cloudburst
6f76352cbe move all possible actions to new api, handle some todos 2024-09-10 21:17:36 +02:00
Cloudburst
9505ca2592 move all app actions to new api, handle some todos 2024-09-09 17:21:45 +02:00
Cloudburst
1d55610685 initial start on the VideoClickAction api 2024-09-08 22:51:19 +02:00
714 changed files with 26850 additions and 57647 deletions

30
.github/locales.py vendored
View file

@ -1,13 +1,14 @@
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-b+"
XML_NAME = "app/src/main/res/values-"
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
INDENT = " "*4
@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER)
# Load already added langs
languages = {}
for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest):
name, iso = lang.groups()
languages[iso] = name
for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
flag, name, iso = lang.groups()
languages[iso] = (flag, name)
# Add not yet added langs
for folder in glob.glob(f"{XML_NAME}*"):
iso = folder[len(XML_NAME):].replace("+", "-")
iso = folder[len(XML_NAME):]
if iso not in languages.keys():
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
entry = iso_map.get(iso.lower(),{'nativeName':iso})
languages[iso] = ("", entry['nativeName'].split(',')[0])
# 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}"),')
# Create triples
triples = []
for iso in sorted(languages.keys()):
flag, name = languages[iso]
triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
# Update settings file
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
before_src +
START_MARKER +
"\n" +
"\n".join(pairs) +
"\n".join(triples) +
"\n" +
END_MARKER +
after_src
@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
with open(file, 'wb') as fp:
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
# 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}")

View file

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

View file

@ -1,18 +1,19 @@
name: Dokka
# 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: [ master ]
branches:
# choose your default branch
- master
- main
paths-ignore:
- '*.md'
permissions:
contents: read
concurrency:
group: "dokka"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
@ -24,44 +25,41 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/dokka"
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@master
with:
path: "src"
- name: Checkout dokka
uses: actions/checkout@v6
uses: actions/checkout@master
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"
rm -rf "./-cloudstream"
- name: Set up JDK 17
uses: actions/setup-java@v5
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
distribution: 'adopt'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Generate Dokka
run: |
cd $GITHUB_WORKSPACE/src/
chmod +x gradlew
./gradlew docs:dokkaGeneratePublicationHtml
./gradlew docs:dokkaHtml
- 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: |

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

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

View file

@ -8,13 +8,10 @@ on:
- '*.json'
- '**/wcokey.txt'
concurrency:
concurrency:
group: "pre-release"
cancel-in-progress: true
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
@ -26,18 +23,14 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: '17'
distribution: 'adopt'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Fetch keystore
id: fetch_keystore
run: |
@ -48,25 +41,18 @@ 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 assemblePrereleaseRelease androidSourcesJar makeJar
run: |
./gradlew assemblePrerelease build androidSourcesJar
./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
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 }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
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"

View file

@ -2,35 +2,22 @@ name: Artifact Build
on: [pull_request]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: '17'
distribution: 'adopt'
- 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 lint check
run: ./gradlew assemblePrereleaseDebug
- name: Upload Artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk"

View file

@ -1,19 +1,17 @@
name: Fix locale issues
on:
workflow_dispatch:
push:
branches: [ master ]
paths:
- '**.xml'
workflow_dispatch:
branches:
- master
concurrency:
concurrency:
group: "locale"
cancel-in-progress: true
permissions:
contents: read
jobs:
create:
runs-on: ubuntu-latest
@ -25,17 +23,15 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream"
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Install dependencies
run: pip3 install lxml requests
run: |
pip3 install lxml
- 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"

220
.gitignore vendored
View file

@ -1,3 +1,5 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/misc.xml
@ -9,220 +11,6 @@
.DS_Store
/build
/captures
.cxx
.kotlin/*
# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode
# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode
### Android ###
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
### Android Patch ###
gen-external-apklibs
# Replacement of .externalNativeBuild directories introduced
# with Android Studio 3.5.
### Java ###
# Compiled class file
*.class
# Log file
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
### Kotlin ###
# Compiled class file
# Log file
# BlueJ files
# Mobile Tools for Java (J2ME)
# Package Files #
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
### VisualStudioCode ###
.vscode/*
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### AndroidStudio ###
# Covers files to be ignored for android development using Android Studio.
# Built application files
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
# Generated files
bin/
gen/
out/
# Gradle files
.gradle
# Signing files
.signing/
# Local configuration file (sdk path, etc)
# Proguard folder generated by Eclipse
proguard/
# Log Files
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
.navigation/
*.ipr
*~
*.swp
# Keystore files
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Android Patch
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# 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
.cxx
local.properties

1
.idea/.name generated Normal file
View file

@ -0,0 +1 @@
CloudStream

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

@ -1,46 +1,11 @@
# CloudStream
**⚠️ Warning: By default, this app doesn't provide any video sources; you have to install extensions to add functionality to the app.**
**⚠️ Warning: By default this app doesn't provide any video sources, you have to install extensions in order to add functionality to the app.**
[![Discord](https://invidget.switchblade.xyz/5Hus6fM)](https://discord.gg/5Hus6fM)
## Table of Contents:
+ [About Us:](#about_us)
+ [Installation Steps:](#install_rules)
+ [Contributing:](#contributing)
+ [Issues:](#issues)
+ [Bugs Reports:](#bug_report)
+ [Enhancement:](#enhancment)
+ [Extension Development:](#extensions)
+ [Language Support:](#languages)
+ [Further Sources](#contact_and_sources)
<a id="about_us"></a>
## About us:
**CloudStream is a media center that prioritizes and emphasizes complete freedom and flexibility for users and developers.**
CloudStream is an extension-based multimedia player with tracking support. There are extensions to view videos from:
+ [Librevox (audio-books)](https://librivox.org/)
+ [Youtube](https://www.youtube.com/)
+ [Twitch](https://www.twitch.tv/)
+ [iptv-org (A collection of publicly available IPTV (Internet Protocol television) channels from all over the world.)](https://github.com/iptv-org/iptv)
+ [nginx](https://nginx.org/)
+ And more...
**Please don't create illegal extensions or use any that host any copyrighted media.** For more details about our stance on the DMCA and EUCD, you can read about it on our organization: [reCloudStream](https://github.com/recloudstream)
#### Important Copyright Note:
Our documentation is unmaintained and open to contributions; therefore, apps and sources, extensions in recommended sources, and recommended apps are not officially moderated or endorsed by CloudStream; if you or another copyright owner identify an extension that breaches your copyright, please let us know.
#### Features:
### Features:
+ **AdFree**, No ads whatsoever
+ No tracking/analytics
+ Bookmarks
@ -48,64 +13,7 @@ Our documentation is unmaintained and open to contributions; therefore, apps and
+ Chromecast
+ Extension system for personal customization
<a id="install_rules"></a>
## Installation:
Our documentation provides the steps to install and configure CloudStream for your streaming needs.
[Getting Started With CloudStream:](https://recloudstream.github.io/csdocs/)
<a id="contributing"></a>
## Contributing:
We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](/cloudstream/issues)
<a id="issues"></a>
### Issues:
While we **actively** accept issues and pull requests, we do require you fill out an [template](https://github.com/recloudstream/cloudstream/issues/new/choose) for issues. These include the following:
<a id="bug_report"></a>
- [Bug Report Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=bug&projects=&template=application-bug.yml)
- For bug reports, we want as much info as possible, including your downloaded version of CloudeStream, device and updated version (if possible, current API),
expected behavior of the program, and the actual behavior that the program did, most importantly we require clear, reproducible steps of the bug. If your bug can't be reproduced, it is unlikely we'll work on your issue.
<a id="enhancment"></a>
- [Feature Request Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml)
- Before adding a feature request, please check to see if a feature request already has been requested.
### Extensions:
**Further details on creating extensions for CloudStream are found in our documentation.**
[Guide: For Extension Developers](https://recloudstream.github.io/csdocs/devs/gettingstarted/)
<a id="contact_and_sources"></a>
## Further Sources:
As well as providing clear install steps, our [website](https://dweb.link/ipns/cloudstream.on.fleek.co/) includes a wide variety of other tools, such as:
- [Troubleshooting](https://recloudstream.github.io/csdocs/troubleshooting/)
- [Further CloudStream Repositories](https://recloudstream.github.io/csdocs/repositories/)
- Set-Up for other devices, such as:
- [Android TV](https://recloudstream.github.io/csdocs/other-devices/tv/)
- [Windows](https://recloudstream.github.io/csdocs/other-devices/windows/)
- [Linux](https://recloudstream.github.io/csdocs/other-devices/linux/)
- And more...
<a id="languages"> </a>
### Supported languages:
Even if you can't contribute to the code or documentation, we always look for those who can contribute to translation and language support. Your contribution is exceptionally appreciated; you can check our translation from the figure below.
<a href="https://hosted.weblate.org/engage/cloudstream/">
<img src="https://hosted.weblate.org/widgets/cloudstream/-/app/multi-auto.svg" alt="Translation status" />
</a>

6
app/CMakeLists.txt Normal file
View file

@ -0,0 +1,6 @@
# Set this to the minimum version your project supports.
cmake_minimum_required(VERSION 3.18)
project(CrashHandler)
find_library(log-lib log)
add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
target_link_libraries(native-lib ${log-lib})

View file

@ -1,96 +1,48 @@
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
import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.ByteArrayOutputStream
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.dokka)
alias(libs.plugins.kotlin.serialization)
id("com.android.application")
id("com.google.devtools.ksp")
id("kotlin-android")
id("org.jetbrains.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()
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<GenerateGitHashTask>("generateGitHash") {
val gitDir = layout.projectDirectory.dir("../.git")
headFile.set(gitDir.file("HEAD"))
headsDir.set(gitDir.dir("refs/heads"))
outputDir.set(layout.buildDirectory.dir("generated/git"))
fun String.execute() = ByteArrayOutputStream().use { baot ->
if (project.exec {
workingDir = projectDir
commandLine = this@execute.split(Regex("\\s"))
standardOutput = baot
}.exitValue == 0)
String(baot.toByteArray()).trim()
else null
}
android {
@Suppress("UnstableApiUsage")
testOptions {
unitTests.isReturnDefaultValues = 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
viewBinding {
enable = true
}
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(
generateGitHash,
GenerateGitHashTask::outputDir
)
/* disable this for now
externalNativeBuild {
cmake {
path("CMakeLists.txt")
}
}
}*/
signingConfigs {
// 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) {
if (prereleaseStoreFile != null) {
create("prerelease") {
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
storeFile = prereleaseStoreFile?.let { file(it) }
storeFile = file(prereleaseStoreFile)
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
@ -98,19 +50,23 @@ android {
}
}
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk = 34
buildToolsVersion = "34.0.0"
defaultConfig {
applicationId = "com.lagradost.cloudstream3"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
minSdk = 21
targetSdk = 33 /* Android 14 is Fu*ked
^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
versionCode = 64
versionName = "4.4.0"
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
resValue("bool", "is_prerelease", "false")
// Reads local.properties
val localProperties = gradleLocalProperties(rootDir, project.providers)
val localProperties = gradleLocalProperties(rootDir)
buildConfigField(
"long",
@ -128,6 +84,11 @@ android {
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("exportSchema", "true")
}
}
buildTypes {
@ -154,9 +115,12 @@ 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")
@ -170,33 +134,17 @@ android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.toVersion(javaTarget.target)
targetCompatibility = JavaVersion.toVersion(javaTarget.target)
}
java {
// Use Java 17 toolchain even if a higher JDK runs the build.
// We still use Java 8 for now which higher JDKs have deprecated.
toolchain {
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
}
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
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"
@ -204,89 +152,105 @@ android {
dependencies {
// Testing
testImplementation(libs.junit)
testImplementation(libs.json)
androidTestImplementation(libs.core)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.instancio.core)
androidTestImplementation(libs.junit.ktx)
androidTestImplementation(libs.kotlin.test)
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test:core")
implementation("androidx.test.ext:junit-ktx:1.2.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
// Android Core & Lifecycle
implementation(libs.core.ktx)
implementation(libs.activity.ktx)
implementation(libs.annotation)
implementation(libs.appcompat)
implementation(libs.fragment.ktx)
implementation(libs.bundles.lifecycle)
implementation(libs.bundles.navigation)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.serialization.json) // JSON Parser
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
// Design & UI
implementation(libs.preference.ktx)
implementation(libs.material)
implementation(libs.constraintlayout)
implementation("jp.wasabeef:glide-transformations:4.3.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// Coil Image Loading
implementation(libs.bundles.coil)
// Glide Module
ksp("com.github.bumptech.glide:ksp:4.16.0")
implementation("com.github.bumptech.glide:glide:4.16.0")
implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
// For KSP -> Official Annotation Processors are Not Yet Supported for KSP
ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
implementation("com.google.guava:guava:33.2.1-android")
implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
// Media 3 (ExoPlayer)
implementation(libs.bundles.media3)
implementation(libs.video)
// FFmpeg Decoding
implementation(libs.bundles.nextlib)
// Anime-db for filler
implementation(libs.anime.db)
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-cast:1.4.1")
implementation("androidx.media3:media3-common:1.4.1")
implementation("androidx.media3:media3-session:1.4.1")
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("com.google.android.mediahome:video:1.0.0")
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
implementation("androidx.media3:media3-exoplayer-dash:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
// PlayBack
implementation(libs.colorpicker) // Subtitle Color Picker
implementation(libs.newpipeextractor) // For Trailers
implementation(libs.juniversalchardet) // Subtitle Decoding
implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs
implementation("com.github.teamnewpipe:NewPipeExtractor:v0.24.2") /* For Trailers
^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */
implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding
// Crash Reports (AcraApplication.kt)
implementation("ch.acra:acra-core:5.11.3")
implementation("ch.acra:acra-toast:5.11.3")
// UI Stuff
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
implementation(libs.palette.ktx) // Palette for Images -> Colors
implementation(libs.tvprovider)
implementation(libs.overlappingpanels) // Gestures
implementation(libs.biometric) // Fingerprint Authentication
implementation(libs.previewseekbar.media3) // SeekBar Preview
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
implementation("com.facebook.shimmer:shimmer:0.5.0") // Shimmering Effect (Loading Skeleton)
implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors
implementation("androidx.tvprovider:tvprovider:1.0.0")
implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures
implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview
implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV
// Extensions & Other Libs
implementation(libs.jsoup) // HTML Parser
implementation(libs.rhino) // Run JavaScript
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) // To Fix SSL Fu*kery on Android 9
implementation(libs.jackson.module.kotlin) // JSON Parser
implementation(libs.zipline)
// Deprecated; will be removed once extensions have time to migrate from using it
implementation("me.xdrop:fuzzywuzzy:1.4.0")
// Torrent Support
implementation(libs.torrentserver)
implementation("org.mozilla:rhino:1.7.15") // run JavaScript
implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance
implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery
implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") /* JSON Parser
^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
Level 25 or Less. */
// Downloading & Networking
implementation(libs.work.runtime.ktx)
implementation(libs.nicehttp) // HTTP Lib
implementation("androidx.work:work-runtime:2.9.0")
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib
implementation(project(":library"))
implementation(project(":library") {
// There does not seem to be a good way of getting the android flavor.
val isDebug = gradle.startParameter.taskRequests.any { task ->
task.args.any { arg ->
arg.contains("debug", true)
}
}
this.extra.set("isDebug", isDebug)
})
}
tasks.register<Jar>("androidSourcesJar") {
archiveClassifier.set("sources")
from(android.sourceSets.getByName("main").java.directories) // Full Sources
from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
}
tasks.register<Copy>("copyJar") {
dependsOn("build", ":library:jvmJar")
from(
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
"build/intermediates/compile_app_classes_jar/prereleaseDebug",
"../library/build/libs"
)
into("build/app-classes")
@ -305,39 +269,12 @@ tasks.register<Jar>("makeJar") {
zipTree("build/app-classes/library-jvm.jar")
)
destinationDirectory.set(layout.buildDirectory)
archiveBaseName = "classes"
archivesName = "classes"
}
tasks.withType<KotlinJvmCompile> {
compilerOptions {
jvmTarget.set(javaTarget)
jvmDefault.set(JvmDefaultMode.ENABLE)
freeCompilerArgs.add("-Xannotation-default-target=param-property")
optIn.addAll(
"com.lagradost.cloudstream3.InternalAPI",
"com.lagradost.cloudstream3.Prerelease",
"kotlin.uuid.ExperimentalUuidApi",
)
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=all-compatibility")
}
}
dokka {
moduleName = "App"
dokkaSourceSets {
configureEach {
suppress = name != "prereleaseDebug"
analysisPlatform = KotlinPlatform.JVM
displayName = "JVM"
documentedVisibilities(
VisibilityModifier.Public,
VisibilityModifier.Protected
)
sourceLink {
localDirectory = file("..")
remoteUrl("https://github.com/recloudstream/cloudstream/tree/master")
remoteLineSuffix = "#L"
}
}
}
}
}

View file

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

View file

@ -7,7 +7,6 @@ import android.view.LayoutInflater
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
@ -89,8 +88,6 @@ class ExampleInstrumentedTest {
// testAllLayouts<ActivityMainBinding>(activity,R.layout.activity_main, R.layout.activity_main_tv)
//testAllLayouts<ActivityMainBinding>(activity, R.layout.activity_main_tv)
testAllLayouts<BottomResultviewPreviewBinding>(activity, R.layout.bottom_resultview_preview,R.layout.bottom_resultview_preview_tv)
testAllLayouts<FragmentPlayerBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
testAllLayouts<FragmentPlayerTvBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
@ -136,14 +133,14 @@ class ExampleInstrumentedTest {
@Test
@Throws(AssertionError::class)
fun providerCorrectData() {
val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag }
Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty())
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
Assert.assertFalse("ISO does not contain any languages", isoNames.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",
langTagsIETF.contains(api.lang)
isoNames.contains(api.lang)
)
Assert.assertTrue(
"Api ${api.name} does not contain any supported types",

View file

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

View file

@ -1,157 +0,0 @@
package com.lagradost.cloudstream3.utils.serializers
import android.net.Uri
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KeepGeneratedSerializer
import kotlinx.serialization.Serializable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = NonEmptyData.Serializer::class)
data class NonEmptyData(
val title: String = "",
val tags: List<String> = emptyList(),
val meta: Map<String, String> = emptyMap(),
val name: String = "hello",
) {
object Serializer : NonEmptySerializer<NonEmptyData>(NonEmptyData.generatedSerializer())
}
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = WriteOnlyData.Serializer::class)
data class WriteOnlyData(
val fieldA: String = "",
val fieldB: String = "",
) {
object Serializer : WriteOnlySerializer<WriteOnlyData>(
WriteOnlyData.generatedSerializer(),
setOf("fieldB"),
)
}
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = MultiWriteOnly.Serializer::class)
data class MultiWriteOnly(
val fieldA: String = "",
val fieldB: String = "",
val fieldC: String = "",
) {
object Serializer : WriteOnlySerializer<MultiWriteOnly>(
MultiWriteOnly.generatedSerializer(),
setOf("fieldB", "fieldC"),
)
}
@Serializable
data class UriData(
@Serializable(with = UriSerializer::class)
val uri: Uri = Uri.EMPTY,
)
class SerializerTest {
@Test
fun nonEmptySerializerOmitsEmptyStrings() {
val data = NonEmptyData(title = "", name = "hello")
val result = data.toJson()
assertFalse(result.contains("title"))
assertTrue(result.contains("name"))
}
@Test
fun nonEmptySerializerOmitsEmptyLists() {
val data = NonEmptyData(tags = emptyList(), name = "hello")
val result = data.toJson()
assertFalse(result.contains("tags"))
}
@Test
fun nonEmptySerializerOmitsEmptyMaps() {
val data = NonEmptyData(meta = emptyMap(), name = "hello")
val result = data.toJson()
assertFalse(result.contains("meta"))
}
@Test
fun nonEmptySerializerKeepsNonEmptyFields() {
val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v"))
val result = data.toJson()
assertTrue(result.contains("title"))
assertTrue(result.contains("tags"))
assertTrue(result.contains("meta"))
}
@Test
fun nonEmptySerializerDoesNotAffectDeserialization() {
val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}"""
val result = parseJson<NonEmptyData>(input)
assertEquals("hello", result.title)
assertEquals(listOf("a"), result.tags)
assertEquals(mapOf("k" to "v"), result.meta)
assertEquals("world", result.name)
}
@Test
fun writeOnlySerializerOmitsFieldOnSerialize() {
val data = WriteOnlyData(fieldA = "hello", fieldB = "secret")
val result = data.toJson()
assertTrue(result.contains("fieldA"))
assertFalse(result.contains("fieldB"))
}
@Test
fun writeOnlySerializerDeserializesNormally() {
val input = """{"fieldA":"hello","fieldB":"secret"}"""
val result = parseJson<WriteOnlyData>(input)
assertEquals("hello", result.fieldA)
assertEquals("secret", result.fieldB)
}
@Test
fun writeOnlySerializerDeserializesMissingAsDefault() {
val input = """{"fieldA":"hello"}"""
val result = parseJson<WriteOnlyData>(input)
assertEquals("hello", result.fieldA)
assertEquals("", result.fieldB)
}
@Test
fun writeOnlySerializerHandlesMultipleKeys() {
val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2")
val result = data.toJson()
assertTrue(result.contains("fieldA"))
assertFalse(result.contains("fieldB"))
assertFalse(result.contains("fieldC"))
}
@Test
fun uriSerializerSerializesUriToString() {
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
val result = data.toJson()
assertTrue(result.contains("https://example.com/path?query=1"))
}
@Test
fun uriSerializerDeserializesStringToUri() {
val input = """{"uri":"https://example.com/path?query=1"}"""
val result = parseJson<UriData>(input)
assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri)
}
@Test
fun uriSerializerRoundtripsCorrectly() {
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
val encoded = data.toJson()
val decoded = parseJson<UriData>(encoded)
assertEquals(data.uri, decoded.uri)
}
}

View file

@ -25,8 +25,9 @@
android:endY="245.72"
android:endX="292.58"
android:type="linear">
<item android:offset="0" android:color="#3FAA11"/>
<item android:offset="1" android:color="#39A11D"/>
<item android:offset="0" android:color="#FF5D49EA"/>
<item android:offset="0.45" android:color="#FF452FE4"/>
<item android:offset="1" android:color="#FF2309DB"/>
</gradient>
</aapt:attr>
</path>
@ -39,8 +40,9 @@
android:endY="245.72"
android:endX="248.76"
android:type="linear">
<item android:offset="0" android:color="#37DB25"/>
<item android:offset="1" android:color="#11DD6D"/>
<item android:offset="0" android:color="#FF4F6DFB"/>
<item android:offset="0.6" android:color="#FF3559E7"/>
<item android:offset="1" android:color="#FF2149D8"/>
</gradient>
</aapt:attr>
</path>
@ -53,45 +55,46 @@
android:endY="245.69"
android:endX="210.03"
android:type="linear">
<item android:offset="0" android:color="#40F15D"/>
<item android:offset="1" android:color="#42C54F"/>
<item android:offset="0" android:color="#FF56B6FE"/>
<item android:offset="0.61" android:color="#FF599CFA"/>
<item android:offset="1" android:color="#FF5C89F7"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M358.81,285q-13.53,0 -22.64,-9.1t-9,-22.72q0,-13.62 9,-22.64 9,-9.18 22.64,-9.19 13.79,0 22.38,10l-5.62,5.44a20.82,20.82 0,0 0,-16.76 -7.91,23 23,0 0,0 -16.94,6.81q-6.72,6.72 -6.72,17.53t6.72,17.53a23,23 0,0 0,16.94 6.81q10.63,0 18.46,-8.94l5.7,5.53a29.57,29.57 0,0 1,-10.63 8A32.44,32.44 0,0 1,358.81 285Z"
android:fillColor="#39A11D"/>
android:fillColor="#2e24ff"/>
<path
android:pathData="M397.78,222.69v60.93H390V222.69Z"
android:fillColor="#39A11D"/>
android:fillColor="#2e24ff"/>
<path
android:pathData="M404.5,262.77q0,-9.61 6,-15.91a20.6,20.6 0,0 1,15.41 -6.3,20.31 20.31,0 0,1 15.31,6.3 21.87,21.87 0,0 1,6.13 15.91q0,9.71 -6.13,15.92A20.3,20.3 0,0 1,426 285a20.6,20.6 0,0 1,-15.41 -6.29Q404.5,272.39 404.5,262.77ZM412.33,262.77a15.31,15.31 0,0 0,3.91 10.9,13.38 13.38,0 0,0 19.41,0 17,17 0,0 0,0 -21.7,13.18 13.18,0 0,0 -19.41,0A15.18,15.18 0,0 0,412.33 262.77Z"
android:fillColor="#39A11D"/>
android:fillColor="#2e24ff"/>
<path
android:pathData="M490.7,283.62h-7.48v-5.78h-0.35a13.86,13.86 0,0 1,-5.48 5.1,15.77 15.77,0 0,1 -7.7,2q-7.67,0 -11.79,-4.38t-4.13,-12.47v-26.2h7.83v25.69q0.25,10.22 10.3,10.22a9.81,9.81 0,0 0,7.83 -3.79,13.7 13.7,0 0,0 3.14,-9.06V241.93h7.83Z"
android:fillColor="#39A11D"/>
android:fillColor="#2e24ff"/>
<path
android:pathData="M517.25,285a18.34,18.34 0,0 1,-14 -6.46,24.34 24.34,0 0,1 0,-31.49 18.35,18.35 0,0 1,14 -6.47,18.07 18.07,0 0,1 8.39,2 14.84,14.84 0,0 1,5.83 5.19h0.34l-0.34,-5.78L531.47,222.69h7.82v60.93h-7.48v-5.78h-0.34a14.84,14.84 0,0 1,-5.83 5.19A18.07,18.07 0,0 1,517.25 285ZM518.53,277.86a12,12 0,0 0,9.45 -4.17q3.82,-4.17 3.83,-10.9A15.54,15.54 0,0 0,528 252a12.05,12.05 0,0 0,-9.45 -4.26,12.19 12.19,0 0,0 -9.44,4.26 15.5,15.5 0,0 0,-3.83 10.8,15.32 15.32,0 0,0 3.83,10.81A12.19,12.19 0,0 0,518.53 277.84Z"
android:fillColor="#39A11D"/>
android:fillColor="#2e24ff"/>
<path
android:pathData="M587.8,267.33a15.91,15.91 0,0 1,-5.87 12.88A22.43,22.43 0,0 1,567.46 285a21.39,21.39 0,0 1,-13.36 -4.42,22.65 22.65,0 0,1 -8,-12.08l7.49,-3.07a19.3,19.3 0,0 0,2.13 4.94,15.72 15.72,0 0,0 3.19,3.78 14.25,14.25 0,0 0,4 2.47,12.26 12.26,0 0,0 4.68,0.9 13.47,13.47 0,0 0,8.76 -2.77,9 9,0 0,0 3.41,-7.36 8.8,8.8 0,0 0,-2.81 -6.55q-2.64,-2.64 -9.87,-5.11 -7.32,-2.64 -9.11,-3.57 -9.69,-4.94 -9.7,-14.55a14.84,14.84 0,0 1,5.37 -11.49A19.53,19.53 0,0 1,567 221.33a20.5,20.5 0,0 1,12.09 3.58,16.67 16.67,0 0,1 6.8,8.76l-7.31,3.06a10.84,10.84 0,0 0,-4 -5.65,13.1 13.1,0 0,0 -15.11,0.28 7.41,7.41 0,0 0,-3.15 6.19,7.14 7.14,0 0,0 2.47,5.42q2.73,2.29 11.83,5.42 9.27,3.17 13.23,7.72A16.53,16.53 0,0 1,587.8 267.33Z"
android:fillColor="#68C671"/>
android:fillColor="#5252ff"/>
<path
android:pathData="M610.26,284.3a11.88,11.88 0,0 1,-8.46 -3.15c-2.25,-2.09 -3.4,-5 -3.45,-8.76V249.07H591v-7.14h7.32V229.16h7.83v12.77h10.21v7.14H606.18v20.77c0,2.78 0.54,4.66 1.61,5.66a5.27,5.27 0,0 0,3.66 1.48,7.9 7.9,0 0,0 1.83,-0.21 9,9 0,0 0,1.66 -0.55l2.47,7A21.23,21.23 0,0 1,610.26 284.3Z"
android:fillColor="#68C671"/>
android:fillColor="#5252ff"/>
<path
android:pathData="M631.71,283.62h-7.83V241.93h7.48v6.8h0.35a11.31,11.31 0,0 1,4.89 -5.66,13.66 13.66,0 0,1 7.27,-2.34 14.7,14.7 0,0 1,5.79 1l-2.38,7.57a12.93,12.93 0,0 0,-4.6 -0.6,10.11 10.11,0 0,0 -7.7,3.58 12,12 0,0 0,-3.27 8.34Z"
android:fillColor="#68C671"/>
android:fillColor="#5252ff"/>
<path
android:pathData="M670.93,285a19.93,19.93 0,0 1,-15.14 -6.29q-6,-6.3 -6,-15.92a22.65,22.65 0,0 1,5.79 -15.87,19.15 19.15,0 0,1 14.8,-6.34q9.29,0 14.77,6t5.49,16.81l-0.09,0.85L657.83,264.24a13.56,13.56 0,0 0,4.08 9.87,13.06 13.06,0 0,0 9.36,3.75q7.49,0 11.75,-7.49l7,3.4a20.69,20.69 0,0 1,-7.78 8.25A21.51,21.51 0,0 1,670.93 285ZM658.42,257.77h23.92a10.43,10.43 0,0 0,-3.53 -7.19,12.38 12.38,0 0,0 -8.56,-2.85 11.34,11.34 0,0 0,-7.61 2.72A13.09,13.09 0,0 0,658.42 257.75Z"
android:fillColor="#68C671"/>
android:fillColor="#5252ff"/>
<path
android:pathData="M714.08,240.56q8.67,0 13.7,4.64c3.34,3.1 5,7.33 5,12.72v25.7h-7.49v-5.78H725Q720.11,285 712,285a16.83,16.83 0,0 1,-11.53 -4.08,13 13,0 0,1 -4.63,-10.21 12.38,12.38 0,0 1,4.89 -10.3q4.89,-3.83 13.06,-3.83a23.16,23.16 0,0 1,11.49 2.55v-1.78a8.9,8.9 0,0 0,-3.24 -6.94,11.08 11.08,0 0,0 -7.57,-2.85 12,12 0,0 0,-10.38 5.53l-6.89,-4.34Q702.93,240.57 714.08,240.56ZM704,270.86a6.24,6.24 0,0 0,2.59 5.1,9.57 9.57,0 0,0 6.09,2.05 12.5,12.5 0,0 0,8.81 -3.66,11.47 11.47,0 0,0 3.87,-8.6q-3.66,-2.88 -10.21,-2.89a13.22,13.22 0,0 0,-8 2.3A6.81,6.81 0,0 0,704 270.86Z"
android:fillColor="#68C671"/>
android:fillColor="#5252ff"/>
<path
android:pathData="M749.47,283.62h-7.82V241.93h7.48v5.78h0.34a14,14 0,0 1,5.49 -5.1,15.06 15.06,0 0,1 7.36,-2.05 15.22,15.22 0,0 1,8.09 2.13,12.56 12.56,0 0,1 5.1,5.87q5.19,-8 14.39,-8 7.23,0 11.14,4.43T805,257.58v26h-7.83V258.77q0,-5.86 -2.13,-8.46t-7.15,-2.6a9.35,9.35 0,0 0,-7.57 3.83,14 14,0 0,0 -3.06,9v23.06h-7.83V258.77q0,-5.86 -2.13,-8.46t-7.15,-2.6a9.35,9.35 0,0 0,-7.57 3.83,14 14,0 0,0 -3.07,9Z"
android:fillColor="#68C671"/>
android:fillColor="#5252ff"/>
<path
android:pathData="M-13.76,555.76c10.3,-20.89 58.91,-113.94 157.31,-139.7C261.3,385.24 405.9,462.43 469.89,613.28">
<aapt:attr name="android:fillColor">
@ -101,9 +104,9 @@
android:endY="252.3"
android:endX="373.57"
android:type="linear">
<item android:offset="0" android:color="#68C671"/>
<item android:offset="0.45" android:color="#11DD6D"/>
<item android:offset="1" android:color="#39A11D"/>
<item android:offset="0" android:color="#FF5D49EA"/>
<item android:offset="0.45" android:color="#FF452FE4"/>
<item android:offset="1" android:color="#FF2309DB"/>
</gradient>
</aapt:attr>
</path>
@ -114,9 +117,9 @@
android:startX="400.11"
android:endX="900"
android:type="linear">
<item android:offset="0" android:color="#68C671"/>
<item android:offset="0.45" android:color="#11DD6D"/>
<item android:offset="1" android:color="#39A11D"/>
<item android:offset="0" android:color="#FF5D49EA"/>
<item android:offset="0.45" android:color="#FF452FE4"/>
<item android:offset="1" android:color="#FF2309DB"/>
</gradient>
</aapt:attr>
</path>
@ -129,9 +132,9 @@
android:endY="252.3"
android:endX="373.57"
android:type="linear">
<item android:offset="0" android:color="#68C671"/>
<item android:offset="0.45" android:color="#11DD6D"/>
<item android:offset="1" android:color="#39A11D"/>
<item android:offset="0" android:color="#FF5D49EA"/>
<item android:offset="0.45" android:color="#FF452FE4"/>
<item android:offset="1" android:color="#FF2309DB"/>
</gradient>
</aapt:attr>
</path>
@ -142,9 +145,9 @@
android:startX="700.11"
android:endX="900.57"
android:type="linear">
<item android:offset="0" android:color="#68C671"/>
<item android:offset="0.45" android:color="#11DD6D"/>
<item android:offset="1" android:color="#39A11D"/>
<item android:offset="0" android:color="#FF5D49EA"/>
<item android:offset="0.45" android:color="#FF452FE4"/>
<item android:offset="1" android:color="#FF2309DB"/>
</gradient>
</aapt:attr>
</path>
@ -155,9 +158,9 @@
android:startX="400.11"
android:endX="800.57"
android:type="linear">
<item android:offset="0" android:color="#68C671"/>
<item android:offset="0.45" android:color="#11DD6D"/>
<item android:offset="1" android:color="#39A11D"/>
<item android:offset="0" android:color="#FF5D49EA"/>
<item android:offset="0.45" android:color="#FF452FE4"/>
<item android:offset="1" android:color="#FF2309DB"/>
</gradient>
</aapt:attr>
</path>

View file

@ -7,62 +7,21 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- Downloads on low api devices -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <!-- Plugin API -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- some dependency needs this -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!-- Used for app update -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Used for app notifications on Android 13+ -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <!-- Used for Android TV watch next -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <!-- We can use this directly as CS3 is not on Play Store -->
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA" /> <!-- We can use to read the tv channel list -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<queries>
<!--
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
-->
<!-- For external video players -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/x-mpegURL" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/vnd.apple.mpegurl" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="magnet" />
</intent>
<!-- Common players supported in actions/temp -->
<package android:name="org.videolan.vlc" />
<package android:name="org.videolan.vlc.debug" />
<package android:name="is.xyz.mpv" />
<package android:name="is.xyz.mpv.ytdl" />
<package android:name="app.marlboroadvance.mpvex" />
<package android:name="live.mehiz.mpvkt" />
<package android:name="live.mehiz.mpvkt.preview" />
<package android:name="com.brouken.player" />
<package android:name="dev.anilbeesetti.nextplayer" />
<package android:name="com.instantbits.cast.webvideo" />
<package android:name="com.gianlu.aria2android" />
<!-- Torrent clients -->
<package android:name="org.proninyaroslav.libretorrent" />
<package android:name="com.biglybt.android.client" />
</queries>
<!-- Fixes android tv fuckery -->
<uses-feature
android:name="android.hardware.touchscreen"
@ -74,8 +33,9 @@
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
<application
android:name=".CloudStreamApp"
android:name=".AcraApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:appCategory="video"
android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/backup_descriptor"
@ -83,12 +43,11 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:pageSizeCompat="enabled"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:targetApi="${target_sdk_version}">
tools:targetApi="tiramisu">
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
@ -106,8 +65,7 @@
android:screenOrientation="userLandscape"
android:supportsPictureInPicture="true"
android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
android:launchMode="singleTask"
tools:ignore="DiscouragedApi">
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -125,55 +83,19 @@
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.OPEN_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="magnet" />
</intent-filter>
<!--<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.OPEN_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/x-bittorrent" />
</intent-filter>-->
</activity>
<!--
android:launchMode="singleTask"
is a bit experimental, it makes loading repositories from browser still stay on the same page
no idea about side effects
Not exported to prevent bypassing the AccountSelectActivity
-->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:exported="false"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:supportsPictureInPicture="true" />
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
android:supportsPictureInPicture="true">
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
<intent-filter>
@ -200,14 +122,7 @@
<data android:scheme="cloudstreamrepo" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="csshare" />
</intent-filter>
<!-- Allow searching with intents: cloudstreamsearch://Your%20Name -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -231,7 +146,7 @@
<data android:scheme="cloudstreamcontinuewatching" />
</intent-filter>
<intent-filter android:autoVerify="false">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -244,6 +159,25 @@
</intent-filter>
</activity>
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.EasterEggMonke"
android:exported="true" />
<receiver
android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false"
@ -254,30 +188,18 @@
</receiver>
<service
android:foregroundServiceType="dataSync"
android:name=".services.VideoDownloadService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name=".services.DownloadQueueService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<activity
android:name=".ui.ControllerActivity"
android:exported="false" />
<service
android:name=".services.PackageInstallerService"
android:foregroundServiceType="dataSync"
android:name=".utils.PackageInstallerService"
android:exported="false" />
<provider

View file

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

View file

@ -1,78 +1,216 @@
package com.lagradost.cloudstream3
/**
* 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 {
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 com.lagradost.api.setContext
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
import com.lagradost.cloudstream3.utils.DataStore.setKey
import kotlinx.coroutines.runBlocking
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.CoreConfiguration
import org.acra.data.CrashReportData
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.ReportSender
import org.acra.sender.ReportSenderFactory
import java.io.File
import java.io.FileNotFoundException
import java.io.PrintStream
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.system.exitProcess
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
level = DeprecationLevel.WARNING
)
val context get() = CloudStreamApp.context
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/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
val data = mapOf(
"entry.1993829403" to errorContent.toJSON()
)
@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)
thread { // to not run it on main thread
runBlocking {
suspendSafeApiCall {
app.post(url, data = data)
//println("Report response: $post")
}
}
}
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
level = DeprecationLevel.WARNING
)
fun <T> setKey(path: String, value: T) =
CloudStreamApp.setKey(path, value)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
level = DeprecationLevel.WARNING
)
fun <T> setKey(folder: String, path: String, value: T) =
CloudStreamApp.setKey(folder, path, value)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
level = DeprecationLevel.WARNING
)
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
CloudStreamApp.getKey(path, defVal)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
level = DeprecationLevel.WARNING
)
inline fun <reified T : Any> getKey(path: String): T? =
CloudStreamApp.getKey(path)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
level = DeprecationLevel.WARNING
)
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
CloudStreamApp.getKey(folder, path)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
level = DeprecationLevel.WARNING
)
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
CloudStreamApp.getKey(folder, path, defVal)
}
runOnMainThread { // to run it on main looper
normalSafeApiCall {
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)
}
}
class AcraApplication : Application() {
override fun onCreate() {
super.onCreate()
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.
}*/
}
}
companion object {
var exceptionHandler: ExceptionHandler? = null
/** Use to get activity from Context */
tailrec fun Context.getActivity(): Activity? = this as? Activity
?: (this as? ContextWrapper)?.baseContext?.getActivity()
private var _context: WeakReference<Context>? = null
var context
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
setContext(WeakReference(value))
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
return context?.getKey(path, valueType)
}
fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
}
fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder)
}
fun <T> setKey(path: String, value: T) {
context?.setKey(path, value)
}
fun <T> setKey(folder: String, path: String, value: T) {
context?.setKey(folder, path, value)
}
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
return context?.getKey(path, defVal)
}
inline fun <reified T : Any> getKey(path: String): T? {
return context?.getKey(path)
}
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
return context?.getKey(folder, path)
}
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
return context?.getKey(folder, path, defVal)
}
fun getKeys(folder: String): List<String>? {
return context?.getKeys(folder)
}
fun removeKey(folder: String, path: String) {
context?.removeKey(folder, path)
}
fun removeKey(path: String) {
context?.removeKey(path)
}
/**
* If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails.
* */
fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) {
context?.openBrowser(url, fallbackWebview, fragment)
}
/** Will fallback to webview if in TV layout */
fun openBrowser(url: String, activity: FragmentActivity?) {
openBrowser(
url,
isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull()
)
}
}
}

View file

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

View file

@ -1,16 +1,13 @@
package com.lagradost.cloudstream3
import android.annotation.SuppressLint
import android.Manifest
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
@ -27,41 +24,30 @@ 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.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.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.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.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.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.result.UiText
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.showInputMethod
import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.UiText
import org.schabi.newpipe.extractor.NewPipe
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,
@ -101,24 +87,17 @@ object CommonActivity {
get() {
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
val screenWidthWithOrientation: Int
get() {
return displayMetrics.widthPixels
}
val screenHeightWithOrientation: Int
get() {
return displayMetrics.heightPixels
}
var isPipDesired: Boolean = false
var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false
var isInPIPMode: Boolean = false
val onColorSelectedEvent = Event<Pair<Int, Int>>()
val onDialogDismissedEvent = Event<Int>()
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
var appliedTheme: Int = 0
var appliedColor: Int = 0
private var currentToast: Toast? = null
@ -185,41 +164,27 @@ object CommonActivity {
val toast = Toast(act)
toast.duration = duration ?: Toast.LENGTH_SHORT
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
@Suppress("DEPRECATION")
toast.view =
binding.root // FIXME Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
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)
}
}
/**
* 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)
* 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)
val resources: Resources = context.resources
val config = resources.configuration
Locale.setDefault(locale)
@ -227,12 +192,7 @@ object CommonActivity {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
context.createConfigurationContext(config)
@Suppress("DEPRECATION")
resources.updateConfiguration(
config,
resources.displayMetrics
) // FIXME this should be replaced
resources.updateConfiguration(config, resources.displayMetrics)
}
fun Context.updateLocale() {
@ -243,27 +203,30 @@ 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 =
componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
val actionUid =
getKey<String>("last_click_action") ?: return@registerForActivityResult
Log.d(TAG, "Loading action $actionUid result handler")
val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction
?: return@registerForActivityResult
action.onResultSafe(act, result.data)
removeKey("last_click_action")
removeKey("last_opened")
}
MainActivity.activityResultLauncher = componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
val actionUid = getKey<String>("last_click_action") ?: return@registerForActivityResult
Log.d(TAG, "Loading action $actionUid result handler")
val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction ?: return@registerForActivityResult
action.onResult(act, result.data)
removeKey("last_click_action")
removeKey("last_opened_id")
}
}
// Ask for notification permissions on Android 13
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
@ -283,22 +246,17 @@ object CommonActivity {
}
}
/** Enters pip mode if it is both possible and desired to do so*/
private fun Activity.enterPIPMode() {
if (!isPipDesired || !this.isPIPPossible()) return
if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
} catch (_: Exception) {
// Use fallback just in case
@Suppress("DEPRECATION")
} catch (e: Exception) {
enterPictureInPictureMode()
}
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@Suppress("DEPRECATION")
enterPictureInPictureMode()
}
}
@ -307,18 +265,17 @@ object CommonActivity {
}
}
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 onUserLeaveHint(act: Activity?) {
if (canEnterPipMode && canShowPipMode) {
act?.enterPIPMode()
}
}
fun updateTheme(act: Activity) {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
if (settingsManager
.getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
) {
.getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
loadThemes(act)
}
}
@ -350,10 +307,6 @@ 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
}
@ -386,13 +339,9 @@ object CommonActivity {
else -> R.style.OverlayPrimaryColorNormal
}
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(
R.style.LoadedStyle,
true
@ -423,7 +372,8 @@ object CommonActivity {
private fun View.hasContent(): Boolean {
return isShown && when (this) {
is ViewGroup -> this.isNotEmpty()
//is RecyclerView -> this.childCount > 0
is ViewGroup -> this.childCount > 0
else -> true
}
}
@ -453,7 +403,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.isNotEmpty()
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
} ?: false
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
@ -531,8 +481,84 @@ object CommonActivity {
}
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
return null
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
// 149 keycode_numpad 5
when (keyCode) {
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
PlayerEventType.SeekForward
}
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
PlayerEventType.SeekBack
}
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
PlayerEventType.NextEpisode
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
PlayerEventType.PrevEpisode
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
PlayerEventType.Pause
}
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
PlayerEventType.Play
}
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
PlayerEventType.Lock
}
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
PlayerEventType.ToggleHide
}
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
PlayerEventType.ToggleMute
}
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
PlayerEventType.ShowMirrors
}
// OpenSubtitles shortcut
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
PlayerEventType.SearchSubtitlesOnline
}
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
PlayerEventType.ShowSpeed
}
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
PlayerEventType.Resize
}
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
PlayerEventType.SkipOp
}
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
PlayerEventType.SkipCurrentChapter
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
PlayerEventType.PlayPauseToggle
}
else -> null
}?.let { playerEvent ->
playerEventListener?.invoke(playerEvent)
}
//when (keyCode) {
// KeyEvent.KEYCODE_DPAD_CENTER -> {
// println("DPAD PRESSED")
// }
//}
}
/** overrides focus and custom key events */
@ -569,7 +595,6 @@ object CommonActivity {
else -> null
}
// println("NEXT FOCUS : $nextView")
if (nextView != null) {
nextView.requestFocus()
@ -577,15 +602,10 @@ 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.
// Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
// send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
@SuppressLint("RestrictedApi")
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) {
showInputMethod(act.currentFocus?.findFocus())
UIHelper.showInputMethod(act.currentFocus?.findFocus())
}
//println("Keycode: $keyCode")
@ -594,6 +614,7 @@ 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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,28 +1,34 @@
package com.lagradost.cloudstream3.actions
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainActivity.Companion.activityResultLauncher
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled
import com.lagradost.cloudstream3.utils.DataStoreHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
fun updateDurationAndPosition(position: Long, duration: Long) {
if (position <= 0 || duration <= 0) return
val episode = getKey<ResultEpisode>("last_opened") ?: return
DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration)
ResultFragment.updateUI()
}
@ -33,13 +39,7 @@ fun updateDurationAndPosition(position: Long, duration: Long) {
fun makeTempM3U8Intent(
context: Context,
intent: Intent,
result: LinkLoadingResult
) {
if (result.links.size == 1) {
intent.setDataAndType(result.links.first().url.toUri(), "video/*")
return
}
result: LinkLoadingResult) {
intent.apply {
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
@ -47,29 +47,37 @@ fun makeTempM3U8Intent(
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
val outputFile = File.createTempFile("mirrorlist", ".m3u8", context.cacheDir)
var text = "#EXTM3U\n#EXT-X-VERSION:3"
val outputDir = context.cacheDir
result.links.forEach { link ->
text += "\n#EXTINF:0,${link.name}\n${link.url}"
if (result.links.size == 1) {
intent.setDataAndType(result.links.first().url.toUri(), "video/*")
} else {
val outputFile = File.createTempFile("mirrorlist", ".m3u8", outputDir)
var text = "#EXTM3U\n#EXT-X-VERSION:3"
result.links.forEachIndexed { index, link ->
text += "\n#EXTINF:$index,${link.name}\n${link.url}"
}
//With subtitles it doesn't work for no reason :(
/*for (sub in result.subs) {
val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "")
text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\""
}*/
text += "\n#EXT-X-ENDLIST"
outputFile.writeText(text)
intent.setDataAndType(
FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".provider",
outputFile
), "application/x-mpegURL"
)
}
//With subtitles it doesn't work for no reason :(
/*for (sub in result.subs) {
val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "")
text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\""
}*/
text += "\n#EXT-X-ENDLIST"
outputFile.writeText(text)
intent.setDataAndType(
FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".provider",
outputFile
), "application/x-mpegURL"
)
}
abstract class OpenInAppAction(
@ -77,16 +85,15 @@ abstract class OpenInAppAction(
open val packageName: String,
private val intentClass: String? = null,
private val action: String = Intent.ACTION_VIEW
) : VideoClickAction() {
): VideoClickAction() {
override val name: UiText
get() = txt(R.string.episode_action_play_in_format, appName)
override val isPlayer = true
override fun shouldShow(context: Context?, video: ResultEpisode?) =
context?.isAppInstalled(packageName) != false
override fun shouldShow(context: Context?, video: ResultEpisode?) = context?.isAppInstalled(packageName) == true
override suspend fun runAction(
override fun runAction(
context: Context?,
video: ResultEpisode,
result: LinkLoadingResult,
@ -99,37 +106,29 @@ abstract class OpenInAppAction(
intent.component = ComponentName(packageName, intentClass)
}
putExtra(context, intent, video, result, index)
setKey("last_opened", video)
launchResult(intent)
setKey("last_opened_id", video.id)
try {
CoroutineScope(Dispatchers.IO).launch {
activityResultLauncher?.launch(intent)
}
} catch (_: ActivityNotFoundException) {
showToast(R.string.app_not_found_error, Toast.LENGTH_LONG)
} catch (t: Throwable) {
logError(t)
showToast(t.toString(), Toast.LENGTH_LONG)
}
}
/**
* Before intent is sent, this function is called to put extra data into the intent.
* @see VideoClickAction.runAction
* */
@Throws
abstract suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
)
abstract fun putExtra(context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int?)
/**
* This function is called when the app is opened again after the intent was sent.
* You can use it to for example update duration and position.
* @see updateDurationAndPosition
*/
@Throws
abstract fun onResult(activity: Activity, intent: Intent?)
/** Safe version of onResult, we don't trust extension devs to not crash the app */
fun onResultSafe(activity: Activity, intent: Intent?) {
try {
onResult(activity, intent)
} catch (t: Throwable) {
logError(t)
}
}
}

View file

@ -1,77 +1,32 @@
package com.lagradost.cloudstream3.actions
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.core.app.ActivityOptionsCompat
import com.lagradost.api.Log
import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage
import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction
import com.lagradost.cloudstream3.actions.temp.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.MpvRxPackage
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
import com.lagradost.cloudstream3.actions.temp.VlcPackage
import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage
import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.UiText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.Callable
import java.util.concurrent.FutureTask
import kotlin.reflect.jvm.jvmName
object VideoClickActionHolder {
val allVideoClickActions = atomicListOf(
// Default
PlayInBrowserAction(),
CopyClipboardAction(),
ViewM3U8Action(),
PlayMirrorAction(),
// main support external apps
VlcPackage(),
MpvPackage(),
MpvExPackage(),
NextPlayerPackage(),
JustPlayerPackage(),
FcastAction(),
LibreTorrentPackage(),
BiglyBTPackage(),
// forks/backup apps
VlcNightlyPackage(),
WebVideoCastPackage(),
MpvYTDLPackage(),
MpvKtPackage(),
MpvKtPreviewPackage(),
OnlyPlayer(),
MpvRxPackage(),
// Always Ask option
AlwaysAskAction(),
// added by plugins
// ...
val allVideoClickActions = threadSafeListOf<VideoClickAction>(
PlayInBrowserAction(), CopyClipboardAction(),
VlcPackage(), ViewM3U8Action(),
MpvPackage(), MpvYTDLPackage(),
WebVideoCastPackage(), MpvKtPackage(), MpvKtPreviewPackage(),
FcastAction()
)
init {
@ -83,7 +38,7 @@ object VideoClickActionHolder {
fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions
// We need to have index before filtering
.mapIndexed { id, it -> it to id + ACTION_ID_OFFSET }
.filter { it.first.shouldShowSafe(activity, video) }
.filter { it.first.shouldShow(activity, video) }
.map { it.first.name to it.second }
@ -99,7 +54,7 @@ object VideoClickActionHolder {
?.second
}
fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(activity, null) }
fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShow(activity, null) }
}
abstract class VideoClickAction {
@ -117,66 +72,10 @@ abstract class VideoClickAction {
/** Determines which plugin a given provider is from. This is the full path to the plugin. */
var sourcePlugin: String? = null
/** Even if VideoClickAction should not run any UI code, startActivity requires it,
* this is a wrapper for runOnUiThread in a suspended safe context that bubble up exceptions */
@Throws
suspend fun <T> uiThread(callable : Callable<T>) : T? {
val future = FutureTask{
try {
Result.success(callable.call())
} catch (t : Throwable) {
Result.failure(t)
}
}
CommonActivity.activity?.runOnUiThread(future) ?: throw ErrorLoadingException("No UI Activity, this should never happened")
val result = withContext(Dispatchers.IO) {
return@withContext future.get()
}
return result.getOrThrow()
}
/** Internally uses activityResultLauncher,
* use this when the activity has a result like watched position */
@Throws
suspend fun launchResult(intent : Intent?, options : ActivityOptionsCompat? = null) {
if (intent == null) {
return
}
uiThread {
MainActivity.activityResultLauncher?.launch(intent,options)
}
}
/** Internally uses startActivity, use this when you don't
* have any result that needs to be stored when exiting the activity */
@Throws
suspend fun launch(intent : Intent?, bundle : Bundle? = null) {
if (intent == null) {
return
}
uiThread {
CommonActivity.activity?.startActivity(intent, bundle)
}
}
fun uniqueId() = "$sourcePlugin:${this::class.jvmName}"
@Throws
abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean
/** Safe version of shouldShow, as we don't trust extension devs to handle exceptions,
* however no dev *should* throw in shouldShow */
fun shouldShowSafe(context: Context?, video: ResultEpisode?): Boolean {
return try {
shouldShow(context,video)
} catch (t : Throwable) {
logError(t)
false
}
}
/**
* This function is called when the action is clicked.
* @param context The current activity
@ -184,22 +83,5 @@ abstract class VideoClickAction {
* @param result The result of the link loading, contains video & subtitle links
* @param index if oneSource is true, this is the index of the selected source
*/
@Throws
abstract suspend fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?)
/** Safe version of runAction, as we don't trust extension devs to handle exceptions */
fun runActionSafe(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) = ioSafe {
try {
runAction(context, video, result, index)
} catch (_ : NotImplementedError) {
CommonActivity.showToast("runAction has not been implemented for ${name.asStringNull(context)}, please contact the extension developer of $sourcePlugin", Toast.LENGTH_LONG)
} catch (error : ErrorLoadingException) {
CommonActivity.showToast(error.message, Toast.LENGTH_LONG)
} catch (_: ActivityNotFoundException) {
CommonActivity.showToast(R.string.app_not_found_error, Toast.LENGTH_LONG)
} catch (t : Throwable) {
logError(t)
CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG)
}
}
}
abstract fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?)
}

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import android.content.Context
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
class CopyClipboardAction: VideoClickAction() {
@ -14,7 +14,7 @@ class CopyClipboardAction: VideoClickAction() {
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
override suspend fun runAction(
override fun runAction(
context: Context?,
video: ResultEpisode,
result: LinkLoadingResult,

View file

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

View file

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

View file

@ -3,12 +3,13 @@ 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
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ExtractorLinkType
@ -33,7 +34,7 @@ open class MpvKtPackage(
ExtractorLinkType.M3U8
)
override suspend fun putExtra(
override fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
@ -44,7 +45,7 @@ open class MpvKtPackage(
intent.apply {
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
setDataAndType(link.url.toUri(), "video/*")
setDataAndType(Uri.parse(link.url), "video/*")
// m3u8 plays, but changing sources feature is not available
// makeTempM3U8Intent(activity, this, result)

View file

@ -10,16 +10,13 @@ import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
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,
@ -28,13 +25,13 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
)
}
open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction(
open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction(
txt(appName),
packageName,
intentClass
"is.xyz.mpv.MPVActivity"
) {
override val oneSource = true // mpv has poor playlist support on TV
override suspend fun putExtra(
override fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
@ -45,11 +42,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
putExtra("title", video.name)
if (index != null) {
setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*")
} else {
makeTempM3U8Intent(context, this, result)
}
makeTempM3U8Intent(context, this, result)
val position = getViewPos(video.id)?.position
if (position != null)

View file

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

View file

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

View file

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

View file

@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.actions.temp
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import android.net.Uri
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.ExtractorLinkType
class PlayInBrowserAction: VideoClickAction() {
@ -25,15 +26,19 @@ class PlayInBrowserAction: VideoClickAction() {
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
override suspend fun runAction(
override fun runAction(
context: Context?,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
val link = result.links.getOrNull(index ?: 0) ?: return
val i = Intent(Intent.ACTION_VIEW)
i.data = link.url.toUri()
launch(i)
try {
val i = Intent(Intent.ACTION_VIEW)
i.data = Uri.parse(link.url)
context?.startActivity(i)
} catch (e: Exception) {
logError(e)
}
}
}

View file

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

View file

@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
class ViewM3U8Action: VideoClickAction() {
override val name = txt(R.string.episode_action_play_in_format, "m3u8 player")
@ -16,7 +16,7 @@ class ViewM3U8Action: VideoClickAction() {
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
override suspend fun runAction(
override fun runAction(
context: Context?,
video: ResultEpisode,
result: LinkLoadingResult,
@ -25,6 +25,6 @@ class ViewM3U8Action: VideoClickAction() {
if (context == null) return
val i = Intent(Intent.ACTION_VIEW)
makeTempM3U8Intent(context, i, result)
launch(i)
context.startActivity(i)
}
}

View file

@ -4,27 +4,21 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.net.toUri
import com.lagradost.api.Log
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
// https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
// https://wiki.videolan.org/Android_Player_Intents/
class VlcNightlyPackage : VlcPackage() {
override val packageName = "org.videolan.vlc.debug"
override val appName = txt("VLC Nightly")
}
open class VlcPackage: OpenInAppAction(
class VlcPackage: OpenInAppAction(
appName = txt("VLC"),
packageName = "org.videolan.vlc",
intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@ -38,21 +32,18 @@ open class VlcPackage: OpenInAppAction(
Intent.ACTION_VIEW
}
) {
// while VLC supports multi links, it has poor support, so we disable it for now
override val oneSource = true
override val oneSource = false
override suspend fun putExtra(
override fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
if (index != null) {
intent.setDataAndType(result.links[index].url.toUri(), "video/*")
} else {
makeTempM3U8Intent(context, intent, result)
}
makeTempM3U8Intent(context, intent, result)
val position = getViewPos(video.id)?.position ?: 0L
intent.putExtra("from_start", false)
@ -60,7 +51,7 @@ open class VlcPackage: OpenInAppAction(
intent.putExtra("secure_uri", true)
intent.putExtra("title", video.name)
val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en"
val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en"
result.subs.firstOrNull {
subsLang == it.languageCode
}?.let {

View file

@ -3,13 +3,14 @@ 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
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.ExtractorLinkType
// https://www.webvideocaster.com/integrations
@ -27,7 +28,7 @@ class WebVideoCastPackage: OpenInAppAction(
ExtractorLinkType.M3U8
)
override suspend fun putExtra(
override fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction(
val link = result.links[index ?: 0]
intent.apply {
setDataAndType(link.url.toUri(), "video/*")
setDataAndType(Uri.parse(link.url), "video/*")
val title = video.name ?: video.headerName

View file

@ -1,13 +1,13 @@
package com.lagradost.cloudstream3.actions.temp.fcast
import android.content.Context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
@ -26,7 +26,7 @@ class FcastAction: VideoClickAction() {
override fun shouldShow(context: Context?, video: ResultEpisode?) = FcastManager.currentDevices.isNotEmpty()
override suspend fun runAction(
override fun runAction(
context: Context?,
video: ResultEpisode,
result: LinkLoadingResult,
@ -34,16 +34,14 @@ class FcastAction: VideoClickAction() {
) {
val link = result.links.getOrNull(index ?: 0) ?: return
val devices = FcastManager.currentDevices.toList()
uiThread {
context?.getActivity()?.showBottomDialog(
devices.map { it.name },
-1,
txt(R.string.player_settings_select_cast_device).asString(context),
false,
{}) {
val position = getViewPos(video.id)?.position
castTo(devices.getOrNull(it), link, position)
}
context?.getActivity()?.showBottomDialog(
devices.map { it.name },
-1,
txt(R.string.player_settings_select_cast_device).asString(context),
false,
{}) {
val position = getViewPos(video.id)?.position
castTo(devices.getOrNull(it), link, position)
}
}
@ -55,7 +53,7 @@ class FcastAction: VideoClickAction() {
session.sendMessage(
Opcode.Play,
PlayMessage(
link.type.getMimeType(),
"video/*",
link.url,
time = position?.let { it / 1000.0 },
headers = mapOf(
@ -66,4 +64,4 @@ class FcastAction: VideoClickAction() {
)
}
}
}
}

View file

@ -5,9 +5,7 @@ import android.net.nsd.NsdManager
import android.net.nsd.NsdManager.ResolveListener
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 {
@ -73,67 +71,24 @@ class FcastManager {
}
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
// 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")
}
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.d(
tag,
"Service updated: ${serviceInfo.serviceName}," +
"Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}"
)
synchronized(_currentDevices) {
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
_currentDevices.add(PublicDeviceInfo(serviceInfo))
}
}
override fun onServiceLost() {
Log.d(tag, "Service lost: ${serviceInfo.serviceName},")
synchronized(_currentDevices) {
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
}
}
override fun onServiceInfoCallbackUnregistered() {}
})
} else {
@Suppress("DEPRECATION")
nsdManager?.resolveService(serviceInfo, object : ResolveListener {
override fun onResolveFailed(
serviceInfo: NsdServiceInfo?,
errorCode: Int
) {
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
if (serviceInfo == null) return
synchronized(_currentDevices) {
_currentDevices.add(PublicDeviceInfo(serviceInfo))
}
Log.d(
tag,
"Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
)
}
})
if (serviceInfo == null) return
nsdManager?.resolveService(serviceInfo, object : ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
}
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
if (serviceInfo == null) return
synchronized(_currentDevices) {
_currentDevices.add(PublicDeviceInfo(serviceInfo))
}
Log.d(
tag,
"Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
)
}
})
}
override fun onServiceLost(serviceInfo: NsdServiceInfo?) {
@ -180,16 +135,6 @@ class FcastManager {
class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
val rawName: String = serviceInfo.serviceName
val host: String? = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
SdkExtensions.getExtensionVersion(
Build.VERSION_CODES.TIRAMISU
) >= 7
) {
serviceInfo.hostAddresses.firstOrNull()?.hostAddress
} else {
@Suppress("DEPRECATION")
serviceInfo.host.hostAddress
}
val host: String? = serviceInfo.host.hostAddress
val name = rawName.replace("-", " ") + host?.let { " $it" }
}
}

View file

@ -1,18 +1,10 @@
package com.lagradost.cloudstream3.metaproviders
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MovieLoadResponse
import com.lagradost.cloudstream3.MovieSearchResponse
import com.lagradost.cloudstream3.SearchResponseList
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.toNewSearchResponseList
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.ExtractorLink
@ -30,9 +22,11 @@ class CrossTmdbProvider : TmdbProvider() {
}
private val validApis
get() = apis.filter { it.lang == this.lang && it::class != this::class }
get() =
synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } }
//.distinctBy { it.uniqueId }
data class CrossMetaData(
@JsonProperty("isSuccess") val isSuccess: Boolean,
@JsonProperty("movies") val movies: List<Pair<String, String>>? = null,
@ -61,12 +55,8 @@ class CrossTmdbProvider : TmdbProvider() {
return false
}
override suspend fun search(query: String, page: Int): SearchResponseList? {
// TODO REMOVE
return super.search(query, page)
?.items
?.filterIsInstance<MovieSearchResponse>()
?.toNewSearchResponseList()
override suspend fun search(query: String): List<SearchResponse>? {
return super.search(query)?.filterIsInstance<MovieSearchResponse>() // TODO REMOVE
}
override suspend fun load(url: String): LoadResponse? {
@ -119,4 +109,4 @@ class CrossTmdbProvider : TmdbProvider() {
return base
}
}
}

View file

@ -1,7 +1,7 @@
package com.lagradost.cloudstream3.metaproviders
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.mvvm.safeAsync
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.SyncIdName
object SyncRedirector {
@ -44,7 +44,7 @@ object SyncRedirector {
return syncIds.firstNotNullOfOrNull { (syncName, syncRegex) ->
if (providerApi.supportedSyncNames.contains(syncName)) {
syncRegex.find(url)?.value?.let {
safeAsync {
suspendSafeApiCall {
providerApi.getLoadUrl(syncName, it)
}
}

View file

@ -1,51 +1,17 @@
package com.lagradost.cloudstream3.metaproviders
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.Actor
import com.lagradost.cloudstream3.Episode
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.HomePageResponse
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.LoadResponse.Companion.addActors
import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainPageRequest
import com.lagradost.cloudstream3.MovieLoadResponse
import com.lagradost.cloudstream3.MovieSearchResponse
import com.lagradost.cloudstream3.ProviderType
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SearchResponseList
import com.lagradost.cloudstream3.TvSeriesLoadResponse
import com.lagradost.cloudstream3.TvSeriesSearchResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.newEpisode
import com.lagradost.cloudstream3.newHomePageResponse
import com.lagradost.cloudstream3.newMovieLoadResponse
import com.lagradost.cloudstream3.newMovieSearchResponse
import com.lagradost.cloudstream3.newTvSeriesLoadResponse
import com.lagradost.cloudstream3.newTvSeriesSearchResponse
import com.lagradost.cloudstream3.runAllAsync
import com.lagradost.cloudstream3.toNewSearchResponseList
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.uwetrottmann.tmdb2.Tmdb
import com.uwetrottmann.tmdb2.entities.AppendToResponse
import com.uwetrottmann.tmdb2.entities.BaseMovie
import com.uwetrottmann.tmdb2.entities.BaseTvShow
import com.uwetrottmann.tmdb2.entities.CastMember
import com.uwetrottmann.tmdb2.entities.ContentRating
import com.uwetrottmann.tmdb2.entities.Movie
import com.uwetrottmann.tmdb2.entities.ReleaseDate
import com.uwetrottmann.tmdb2.entities.ReleaseDatesResult
import com.uwetrottmann.tmdb2.entities.TvSeason
import com.uwetrottmann.tmdb2.entities.TvShow
import com.uwetrottmann.tmdb2.entities.Videos
import com.uwetrottmann.tmdb2.entities.*
import com.uwetrottmann.tmdb2.enumerations.AppendToResponseItem
import com.uwetrottmann.tmdb2.enumerations.VideoType
import retrofit2.awaitResponse
import retrofit2.Response
import java.util.Calendar
import java.util.*
/**
* episode and season starting from 1
@ -88,39 +54,36 @@ open class TmdbProvider : MainAPI() {
}
private fun BaseTvShow.toSearchResponse(): TvSeriesSearchResponse {
return newTvSeriesSearchResponse(
name = this.name ?: this.original_name,
url = getUrl(id, true),
type = TvType.TvSeries,
fix = false
) {
this.id = this@toSearchResponse.id
this.posterUrl = getImageUrl(poster_path)
this.score = Score.from10(vote_average)
this.year = first_air_date?.let {
return TvSeriesSearchResponse(
this.name ?: this.original_name,
getUrl(id, true),
apiName,
TvType.TvSeries,
getImageUrl(this.poster_path),
this.first_air_date?.let {
Calendar.getInstance().apply {
time = it
}.get(Calendar.YEAR)
}
}
},
null,
this.id
)
}
private fun BaseMovie.toSearchResponse(): MovieSearchResponse {
return newMovieSearchResponse(
name = this.title ?: this.original_title,
url = getUrl(id, false),
type = TvType.Movie,
fix = false
) {
this.id = this@toSearchResponse.id
this.posterUrl = getImageUrl(poster_path)
this.score = Score.from10(vote_average)
this.year = release_date?.let {
return MovieSearchResponse(
this.title ?: this.original_title,
getUrl(id, false),
apiName,
TvType.TvSeries,
getImageUrl(this.poster_path),
this.release_date?.let {
Calendar.getInstance().apply {
time = it
}.get(Calendar.YEAR)
}
}
},
this.id,
)
}
private fun List<CastMember?>?.toActors(): List<Pair<Actor, String?>>? {
@ -133,39 +96,39 @@ open class TmdbProvider : MainAPI() {
}
private suspend fun TvShow.toLoadResponse(): TvSeriesLoadResponse {
val tvSeasonsService = tmdb.tvSeasonsService()
val episodes = mutableListOf<Episode>()
val validSeasons = this.seasons?.filter { !disableSeasonZero || (it.season_number ?: 0) != 0 } ?: emptyList()
for (season in validSeasons) {
val seasonNumber = season.season_number ?: continue
val response: Response<TvSeason> = tmdb.tvSeasonsService()
.season(this.id, seasonNumber, "external_ids,images,episodes")
.awaitResponse()
val fullSeason = response.body() ?: continue
fullSeason.episodes?.forEach { episode ->
episodes += newEpisode(
TmdbLink(
episode.external_ids?.imdb_id ?: this.external_ids?.imdb_id,
this.id,
episode.episode_number,
val episodes = this.seasons?.filter { !disableSeasonZero || (it.season_number ?: 0) != 0 }
?.mapNotNull { season ->
season.episodes?.map { episode ->
Episode(
TmdbLink(
episode.external_ids?.imdb_id ?: this.external_ids?.imdb_id,
this.id,
episode.episode_number,
episode.season_number,
this.name ?: this.original_name,
).toJson(),
episode.name,
episode.season_number,
this.name ?: this.original_name
).toJson()
) {
this.name = episode.name
this.season = episode.season_number
this.episode = episode.episode_number
this.score = Score.from10(episode.vote_average)
this.description = episode.overview
this.date = episode.air_date?.time
this.posterUrl = getImageUrl(episode.still_path)
episode.episode_number,
getImageUrl(episode.still_path),
episode.rating,
episode.overview,
episode.air_date?.time,
)
} ?: (1..(season.episode_count ?: 1)).map { episodeNum ->
Episode(
episode = episodeNum,
data = TmdbLink(
this.external_ids?.imdb_id,
this.id,
episodeNum,
season.season_number,
this.name ?: this.original_name,
).toJson(),
season = season.season_number
)
}
}
}
}?.flatten() ?: listOf()
return newTvSeriesLoadResponse(
this.name ?: this.original_name,
@ -181,13 +144,16 @@ open class TmdbProvider : MainAPI() {
}
plot = overview
addImdbId(external_ids?.imdb_id)
tags = genres?.mapNotNull { it.name }
duration = episode_run_time?.average()?.toInt()
score = Score.from10(vote_average)
rating = this@toLoadResponse.rating
addTrailer(videos.toTrailers())
recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors())
contentRating = fetchContentRating(id, "US")
}
}
@ -225,7 +191,7 @@ open class TmdbProvider : MainAPI() {
addImdbId(external_ids?.imdb_id)
tags = genres?.mapNotNull { it.name }
duration = runtime
score = Score.from10(vote_average)
rating = this@toLoadResponse.rating
addTrailer(videos.toTrailers())
recommendations = (this@toLoadResponse.recommendations
@ -236,15 +202,15 @@ open class TmdbProvider : MainAPI() {
}
}
override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse {
override suspend fun getMainPage(page: Int, request : MainPageRequest): HomePageResponse {
// SAME AS DISCOVER IT SEEMS
// val popularSeries = tmdb.tvService().popular(page, "en-US").execute().body()?.results?.map {
// val popularSeries = tmdb.tvService().popular(1, "en-US").execute().body()?.results?.map {
// it.toSearchResponse()
// } ?: listOf()
//
// val popularMovies =
// tmdb.moviesService().popular(page, "en-US", "840").execute().body()?.results?.map {
// tmdb.moviesService().popular(1, "en-US", "840").execute().body()?.results?.map {
// it.toSearchResponse()
// } ?: listOf()
@ -252,31 +218,31 @@ open class TmdbProvider : MainAPI() {
var discoverSeries: List<TvSeriesSearchResponse> = listOf()
var topMovies: List<MovieSearchResponse> = listOf()
var topSeries: List<TvSeriesSearchResponse> = listOf()
runAllAsync(
argamap(
{
discoverMovies = tmdb.discoverMovie().page(page).build().awaitResponse().body()?.results?.map {
discoverMovies = tmdb.discoverMovie().build().awaitResponse().body()?.results?.map {
it.toSearchResponse()
} ?: listOf()
}, {
discoverSeries = tmdb.discoverTv().page(page).build().awaitResponse().body()?.results?.map {
discoverSeries = tmdb.discoverTv().build().awaitResponse().body()?.results?.map {
it.toSearchResponse()
} ?: listOf()
}, {
// https://en.wikipedia.org/wiki/ISO_3166-1
topMovies =
tmdb.moviesService().topRated(page, "en-US", "US").awaitResponse()
tmdb.moviesService().topRated(1, "en-US", "US").awaitResponse()
.body()?.results?.map {
it.toSearchResponse()
} ?: listOf()
}, {
topSeries =
tmdb.tvService().topRated(page, "en-US").awaitResponse().body()?.results?.map {
tmdb.tvService().topRated(1, "en-US").awaitResponse().body()?.results?.map {
it.toSearchResponse()
} ?: listOf()
}
)
return newHomePageResponse(
return HomePageResponse(
listOf(
// HomePageList("Popular Series", popularSeries),
// HomePageList("Popular Movies", popularMovies),
@ -396,27 +362,29 @@ open class TmdbProvider : MainAPI() {
} else {
loadFromTmdb(id)?.let { return it }
if (isTvSeries) {
tmdb.tvService().externalIds(id).awaitResponse().body()?.imdb_id?.let {
tmdb.tvService().externalIds(id, "en-US").awaitResponse().body()?.imdb_id?.let {
val fromImdb = loadFromImdb(it)
val result = if (fromImdb == null) {
val details = tmdb.tvService().tv(id, "en-US").awaitResponse().body()
loadFromImdb(it, details?.seasons ?: listOf())
?: loadFromTmdb(id, details?.seasons ?: listOf())
} else fromImdb
} else {
fromImdb
}
result
}
} else {
tmdb.moviesService().externalIds(id).awaitResponse()
tmdb.moviesService().externalIds(id, "en-US").awaitResponse()
.body()?.imdb_id?.let { loadFromImdb(it) }
}
}
}
override suspend fun search(query: String, page: Int): SearchResponseList? {
return tmdb.searchService().multi(query, page, "en-US", "US", includeAdult).awaitResponse()
override suspend fun search(query: String): List<SearchResponse>? {
return tmdb.searchService().multi(query, 1, "en-Us", "US", includeAdult).awaitResponse()
.body()?.results?.mapNotNull {
it.movie?.toSearchResponse() ?: it.tvShow?.toSearchResponse()
}?.toNewSearchResponseList()
}
}
}
}

View file

@ -1,8 +1,9 @@
package com.lagradost.cloudstream3.metaproviders
import android.net.Uri
import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.api.BuildConfig
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.Actor
import com.lagradost.cloudstream3.ActorData
@ -16,24 +17,24 @@ import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainPageRequest
import com.lagradost.cloudstream3.NextAiring
import com.lagradost.cloudstream3.ProviderType
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.SearchResponseList
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.addDate
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.isUpcoming
import com.lagradost.cloudstream3.base64Decode
import com.lagradost.cloudstream3.mainPageOf
import com.lagradost.cloudstream3.newEpisode
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.newHomePageResponse
import com.lagradost.cloudstream3.newMovieLoadResponse
import com.lagradost.cloudstream3.newMovieSearchResponse
import com.lagradost.cloudstream3.newSearchResponseList
import com.lagradost.cloudstream3.newTvSeriesLoadResponse
import com.lagradost.cloudstream3.newTvSeriesSearchResponse
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.roundToInt
open class TraktProvider : MainAPI() {
override var name = "Trakt"
@ -45,9 +46,9 @@ open class TraktProvider : MainAPI() {
TvType.Anime,
)
private val traktApiUrl = "https://api.trakt.tv"
val traktClientId: String = BuildConfig.TRAKT_CLIENT_ID
private val traktClientId =
base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==")
private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2")
override val mainPage = mainPageOf(
"$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now
@ -57,7 +58,8 @@ open class TraktProvider : MainAPI() {
)
override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse {
val apiResponse = getApi("${request.data}?extended=full,images&page=$page")
val apiResponse = getApi("${request.data}?extended=cloud9,full&page=$page")
val results = parseJson<List<MediaDetails>>(apiResponse).map { element ->
element.toSearchResponse()
@ -70,76 +72,76 @@ open class TraktProvider : MainAPI() {
val media = this.media ?: this
val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries
val poster = media.images?.poster?.firstOrNull()
return if (mediaType == TvType.Movie) {
newMovieSearchResponse(
name = media.title ?: "",
if (mediaType == TvType.Movie) {
return newMovieSearchResponse(
name = media.title!!,
url = Data(
type = mediaType,
mediaDetails = media,
).toJson(),
type = TvType.Movie,
) {
score = Score.from10(media.rating)
posterUrl = fixPath(poster)
}
} else {
newTvSeriesSearchResponse(
name = media.title ?: "",
return newTvSeriesSearchResponse(
name = media.title!!,
url = Data(
type = mediaType,
mediaDetails = media,
).toJson(),
type = TvType.TvSeries,
) {
score = Score.from10(media.rating)
this.posterUrl = fixPath(poster)
}
}
}
override suspend fun search(query: String, page: Int): SearchResponseList? {
override suspend fun search(query: String): List<SearchResponse>? {
val apiResponse =
getApi("$traktApiUrl/search/movie,show?extended=full,images&limit=20&page=$page&query=$query")
getApi("$traktApiUrl/search/movie,show?extended=cloud9,full&limit=20&page=1&query=$query")
return newSearchResponseList(parseJson<List<MediaDetails>>(apiResponse).map { element ->
val results = parseJson<List<MediaDetails>>(apiResponse).map { element ->
element.toSearchResponse()
})
}
return results
}
override suspend fun load(url: String): LoadResponse {
val data = parseJson<Data>(url)
val mediaDetails = data.mediaDetails
val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows"
val posterUrl = fixPath(mediaDetails?.images?.poster?.firstOrNull())
val backDropUrl = fixPath(mediaDetails?.images?.fanart?.firstOrNull())
val logoUrl = fixPath(mediaDetails?.images?.logo?.firstOrNull())
val posterUrl = mediaDetails?.images?.poster?.firstOrNull()
val backDropUrl = mediaDetails?.images?.fanart?.firstOrNull()
val resActor =
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=full,images")
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=cloud9,full")
val actors = parseJson<People>(resActor).cast?.map {
ActorData(
Actor(
name = it.person?.name!!,
image = fixPath(it.person.images?.headshot?.firstOrNull())
image = getWidthImageUrl(it.person.images?.headshot?.firstOrNull(), "w500")
),
roleString = it.character
)
}
val resRelated =
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=full,images&limit=20")
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=cloud9,full&limit=20")
val relatedMedia = parseJson<List<MediaDetails>>(resRelated).map { it.toSearchResponse() }
val isCartoon =
mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true
val isAnime =
isCartoon && (mediaDetails.language == "zh" || mediaDetails.language == "ja")
isCartoon && (mediaDetails?.language == "zh" || mediaDetails?.language == "ja")
val isAsian = !isAnime && (mediaDetails?.language == "zh" || mediaDetails?.language == "ko")
val isBollywood = mediaDetails?.country == "in"
val uniqueUrl = data.mediaDetails?.ids?.trakt?.toJson() ?: data.toJson()
if (data.type == TvType.Movie) {
@ -169,21 +171,19 @@ open class TraktProvider : MainAPI() {
dataUrl = linkData.toJson(),
type = if (isAnime) TvType.AnimeMovie else TvType.Movie,
) {
this.uniqueUrl = uniqueUrl
this.name = mediaDetails.title
this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie
this.posterUrl = posterUrl
this.posterUrl = getOriginalWidthImageUrl(posterUrl)
this.year = mediaDetails.year
this.plot = mediaDetails.overview
this.score = Score.from10(mediaDetails.rating)
this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
this.tags = mediaDetails.genres
this.duration = mediaDetails.runtime
this.recommendations = relatedMedia
this.actors = actors
this.comingSoon = isUpcoming(mediaDetails.released)
//posterHeaders
this.backgroundPosterUrl = backDropUrl
this.logoUrl = logoUrl
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
this.contentRating = mediaDetails.certification
addTrailer(mediaDetails.trailer)
addImdbId(mediaDetails.ids?.imdb)
@ -192,7 +192,7 @@ open class TraktProvider : MainAPI() {
} else {
val resSeasons =
getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=full,images,episodes")
getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes")
val episodes = mutableListOf<Episode>()
val seasons = parseJson<List<Seasons>>(resSeasons)
var nextAir: NextAiring? = null
@ -228,16 +228,16 @@ open class TraktProvider : MainAPI() {
).toJson()
episodes.add(
newEpisode(linkData.toJson()) {
this.name = episode.title
this.season = episode.season
this.episode = episode.number
this.description = episode.overview
this.runTime = episode.runtime
this.posterUrl = fixPath( episode.images?.screenshot?.firstOrNull())
//this.rating = episode.rating?.times(10)?.roundToInt()
this.score = Score.from10(episode.rating)
Episode(
data = linkData.toJson(),
name = episode.title,
season = episode.season,
episode = episode.number,
posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()),
rating = episode.rating?.times(10)?.roundToInt(),
description = episode.overview,
runTime = episode.runtime
).apply {
this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) {
nextAir = NextAiring(
@ -257,15 +257,14 @@ open class TraktProvider : MainAPI() {
type = if (isAnime) TvType.Anime else TvType.TvSeries,
episodes = episodes
) {
this.uniqueUrl = uniqueUrl
this.name = mediaDetails.title
this.type = if (isAnime) TvType.Anime else TvType.TvSeries
this.episodes = episodes
this.posterUrl = posterUrl
this.posterUrl = getOriginalWidthImageUrl(posterUrl)
this.year = mediaDetails.year
this.plot = mediaDetails.overview
this.showStatus = getStatus(mediaDetails.status)
this.score = Score.from10(mediaDetails.rating)
this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
this.tags = mediaDetails.genres
this.duration = mediaDetails.runtime
this.recommendations = relatedMedia
@ -273,8 +272,7 @@ open class TraktProvider : MainAPI() {
this.comingSoon = isUpcoming(mediaDetails.released)
//posterHeaders
this.nextAiring = nextAir
this.backgroundPosterUrl = backDropUrl
this.logoUrl = logoUrl
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
this.contentRating = mediaDetails.certification
addTrailer(mediaDetails.trailer)
addImdbId(mediaDetails.ids?.imdb)
@ -291,7 +289,18 @@ open class TraktProvider : MainAPI() {
"trakt-api-version" to "2",
"trakt-api-key" to traktClientId,
)
).text
).toString()
}
private fun isUpcoming(dateString: String?): Boolean {
return try {
val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val dateTime = dateString?.let { format.parse(it)?.time } ?: return false
unixTimeMS < dateTime
} catch (t: Throwable) {
logError(t)
false
}
}
private fun getStatus(t: String?): ShowStatus {
@ -307,6 +316,19 @@ open class TraktProvider : MainAPI() {
return "https://$url"
}
private fun getWidthImageUrl(path: String?, width: String): String? {
if (path == null) return null
if (!path.contains("image.tmdb.org")) return fixPath(path)
val fileName = Uri.parse(path).lastPathSegment ?: return null
return "https://image.tmdb.org/t/p/${width}/${fileName}"
}
private fun getOriginalWidthImageUrl(path: String?): String? {
if (path == null) return null
if (!path.contains("image.tmdb.org")) return fixPath(path)
return getWidthImageUrl(path, "original")
}
data class Data(
val type: TvType? = null,
val mediaDetails: MediaDetails? = null,
@ -357,10 +379,10 @@ open class TraktProvider : MainAPI() {
)
data class Images(
@JsonProperty("poster") val poster: List<String>? = null,
@JsonProperty("fanart") val fanart: List<String>? = null,
@JsonProperty("poster") val poster: List<String>? = null,
@JsonProperty("logo") val logo: List<String>? = null,
@JsonProperty("clearart") val clearArt: List<String>? = null,
@JsonProperty("clearart") val clearart: List<String>? = null,
@JsonProperty("banner") val banner: List<String>? = null,
@JsonProperty("thumb") val thumb: List<String>? = null,
@JsonProperty("screenshot") val screenshot: List<String>? = null,
@ -420,30 +442,30 @@ open class TraktProvider : MainAPI() {
)
data class LinkData(
@JsonProperty("id") val id: Int? = null,
@JsonProperty("trakt_id") val traktId: Int? = null,
@JsonProperty("trakt_slug") val traktSlug: String? = null,
@JsonProperty("tmdb_id") val tmdbId: Int? = null,
@JsonProperty("imdb_id") val imdbId: String? = null,
@JsonProperty("tvdb_id") val tvdbId: Int? = null,
@JsonProperty("tvrage_id") val tvrageId: String? = null,
@JsonProperty("type") val type: String? = null,
@JsonProperty("season") val season: Int? = null,
@JsonProperty("episode") val episode: Int? = null,
@JsonProperty("ani_id") val aniId: String? = null,
@JsonProperty("anime_id") val animeId: String? = null,
@JsonProperty("title") val title: String? = null,
@JsonProperty("year") val year: Int? = null,
@JsonProperty("org_title") val orgTitle: String? = null,
@JsonProperty("is_anime") val isAnime: Boolean = false,
@JsonProperty("aired_year") val airedYear: Int? = null,
@JsonProperty("last_season") val lastSeason: Int? = null,
@JsonProperty("eps_title") val epsTitle: String? = null,
@JsonProperty("jp_title") val jpTitle: String? = null,
@JsonProperty("date") val date: String? = null,
@JsonProperty("aired_date") val airedDate: String? = null,
@JsonProperty("is_asian") val isAsian: Boolean = false,
@JsonProperty("is_bollywood") val isBollywood: Boolean = false,
@JsonProperty("is_cartoon") val isCartoon: Boolean = false,
val id: Int? = null,
val traktId: Int? = null,
val traktSlug: String? = null,
val tmdbId: Int? = null,
val imdbId: String? = null,
val tvdbId: Int? = null,
val tvrageId: String? = null,
val type: String? = null,
val season: Int? = null,
val episode: Int? = null,
val aniId: String? = null,
val animeId: String? = null,
val title: String? = null,
val year: Int? = null,
val orgTitle: String? = null,
val isAnime: Boolean = false,
val airedYear: Int? = null,
val lastSeason: Int? = null,
val epsTitle: String? = null,
val jpTitle: String? = null,
val date: String? = null,
val airedDate: String? = null,
val isAsian: Boolean = false,
val isBollywood: Boolean = false,
val isCartoon: Boolean = false,
)
}
}

View file

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

View file

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

View file

@ -84,24 +84,4 @@ fun OkHttpClient.Builder.addQuad9Dns() = (
"9.9.9.9",
"149.112.112.112",
)
))
fun OkHttpClient.Builder.addDnsSbDns() = (
addGenericDns(
"https://doh.dns.sb/dns-query",
//https://dns.sb/guide/
listOf(
"185.222.222.222",
"45.11.45.11",
)
))
fun OkHttpClient.Builder.addCanadianShieldDns() = (
addGenericDns(
"https://private.canadianshield.cira.ca/dns-query",
//https://www.cira.ca/en/canadian-shield/configure/summary-cira-canadian-shield-dns-resolver-addresses/
listOf(
"149.112.121.10",
"149.112.122.10",
)
))
))

View file

@ -2,10 +2,9 @@ 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
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.ignoreAllSSLErrors
import okhttp3.Cache
@ -16,38 +15,14 @@ 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) }
fun Requests.initClient(context: Context): OkHttpClient {
normalSafeApiCall { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0)
val baseClient = OkHttpClient.Builder()
baseClient = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.apply {
if (ignoreSSL) {
ignoreAllSSLErrors()
}
}
.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.
@ -63,8 +38,6 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
4 -> addAdGuardDns()
5 -> addDNSWatchDns()
6 -> addQuad9Dns()
7 -> addDnsSbDns()
8 -> addCanadianShieldDns()
}
}
// Needs to be build as otherwise the other builders will change this object
@ -72,6 +45,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
return baseClient
}
//val Request.cookies: Map<String, String>
// get() {
// return this.headers.getCookies("Cookie")
// }
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
/**

View file

@ -2,20 +2,56 @@ package com.lagradost.cloudstream3.plugins
import android.content.Context
import android.content.res.Resources
import kotlin.Throws
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.extractorApis
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import kotlin.Throws
abstract class Plugin : BasePlugin() {
const val PLUGIN_TAG = "PluginInstance"
abstract class Plugin {
/**
* Called when your Plugin is loaded
* @param context Context
*/
@Throws(Throwable::class)
open fun load(context: Context) {
// If not overridden by an extension then try the cross-platform load()
load()
}
/**
* Called when your Plugin is being unloaded
*/
@Throws(Throwable::class)
open fun beforeUnload() {
}
/**
* Used to register providers instances of MainAPI
* @param element MainAPI provider you want to register
*/
fun registerMainAPI(element: MainAPI) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
element.sourcePlugin = this.filename
// Race condition causing which would case duplicates if not for distinctBy
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.add(element)
}
APIHolder.addPluginMapping(element)
}
/**
* Used to register extractor instances of ExtractorApi
* @param element ExtractorApi provider you want to register
*/
fun registerExtractorAPI(element: ExtractorApi) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi")
element.sourcePlugin = this.filename
extractorApis.add(element)
}
/**
@ -25,16 +61,35 @@ abstract class Plugin : BasePlugin() {
fun registerVideoClickAction(element: VideoClickAction) {
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
element.sourcePlugin = this.filename
VideoClickActionHolder.allVideoClickActions.add(element)
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.add(element)
}
}
class Manifest {
@JsonProperty("name")
var name: String? = null
@JsonProperty("pluginClassName")
var pluginClassName: String? = null
@JsonProperty("version")
var version: Int? = null
@JsonProperty("requiresResources")
var requiresResources: Boolean = false
}
/**
* This will contain your resources if you specified requiresResources in gradle
*/
var resources: Resources? = null
/** Full file path to the plugin. */
@Deprecated("Renamed to `filename` to follow conventions", replaceWith = ReplaceWith("filename"))
var __filename: String?
get() = filename
set(value) {filename = value}
var filename: String? = null
/**
* This will add a button in the settings allowing you to add custom settings
*/
var openSettings: ((context: Context) -> Unit)? = null
}
}

View file

@ -1,10 +1,7 @@
package com.lagradost.cloudstream3.plugins
import android.Manifest
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.*
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.AssetManager
@ -13,56 +10,45 @@ 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
import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.google.gson.Gson
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
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.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.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
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER
import com.lagradost.cloudstream3.plugins.RepositoryManager.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.result.UiText
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
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.downloader.DownloadFileManagement.sanitizeFilename
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.txt
import dalvik.system.PathClassLoader
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.io.InputStreamReader
import java.util.*
// Different keys for local and not since local can be removed at any time without app knowing, hence the local are getting rebuilt on every app start
const val PLUGINS_KEY = "PLUGINS_KEY"
@ -80,7 +66,6 @@ data class PluginData(
@JsonProperty("filePath") val filePath: String,
@JsonProperty("version") val version: Int,
) {
@WorkerThread
fun toSitePlugin(): SitePlugin {
return SitePlugin(
this.filePath,
@ -95,9 +80,7 @@ data class PluginData(
null,
null,
null,
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
File(this.filePath).length()
)
}
}
@ -151,7 +134,7 @@ object PluginManager {
!it.filePath.contains(repositoryPath)
}
val file = File(repositoryPath)
safe {
normalSafeApiCall {
if (file.exists()) file.deleteRecursively()
}
setKey(PLUGINS_KEY, plugins)
@ -188,21 +171,22 @@ object PluginManager {
var currentlyLoading: String? = null
// Maps filepath to plugin
val plugins: MutableMap<String, BasePlugin> =
LinkedHashMap<String, BasePlugin>()
val plugins: MutableMap<String, Plugin> =
LinkedHashMap<String, Plugin>()
// Maps urls to plugin
val urlPlugins: MutableMap<String, BasePlugin> =
LinkedHashMap<String, BasePlugin>()
val urlPlugins: MutableMap<String, Plugin> =
LinkedHashMap<String, Plugin>()
private val classLoaders: MutableMap<PathClassLoader, BasePlugin> =
HashMap<PathClassLoader, BasePlugin>()
private val classLoaders: MutableMap<PathClassLoader, Plugin> =
HashMap<PathClassLoader, Plugin>()
var loadedLocalPlugins = false
private set
var loadedOnlinePlugins = false
private set
private val gson = Gson()
private suspend fun maybeLoadPlugin(context: Context, file: File) {
val name = file.name
@ -261,24 +245,16 @@ object PluginManager {
* 2. If disabled do nothing
* 3. If outdated download and load the plugin
* 4. Else load the plugin normally
*
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
assertNonRecursiveCallstack()
**/
fun updateAllOnlinePluginsAndLoadThem(activity: Activity) {
// Load all plugins as fast as possible!
___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity)
loadAllOnlinePlugins(activity)
afterPluginsLoadedEvent.invoke(false)
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES
val onlinePlugins = urls.toList().amap {
val onlinePlugins = urls.toList().apmap {
getRepoPlugins(it.url)?.toList() ?: emptyList()
}.flatten().distinctBy { it.second.url }
@ -299,7 +275,7 @@ object PluginManager {
val updatedPlugins = mutableListOf<String>()
outdatedPlugins.amap { pluginData ->
outdatedPlugins.apmap { pluginData ->
if (pluginData.isDisabled) {
//updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name))
unloadPlugin(pluginData.savedData.filePath)
@ -307,7 +283,6 @@ object PluginManager {
downloadPlugin(
activity,
pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
File(pluginData.savedData.filePath),
true
@ -339,23 +314,12 @@ object PluginManager {
* 1. Gets all online data from online plugins repo
* 2. Fetch all not downloaded plugins
* 3. Download them and reload plugins
*
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
activity: Activity,
mode: AutoDownloadMode
) {
assertNonRecursiveCallstack()
**/
fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
val newDownloadPlugins = mutableListOf<String>()
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES
val onlinePlugins = urls.toList().amap {
val onlinePlugins = urls.toList().apmap {
getRepoPlugins(it.url)?.toList() ?: emptyList()
}.flatten().distinctBy { it.second.url }
@ -415,11 +379,10 @@ object PluginManager {
}
//Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}")
notDownloadedPlugins.amap { pluginData ->
notDownloadedPlugins.apmap { pluginData ->
downloadPlugin(
activity,
pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
pluginData.onlineData.first,
!pluginData.isDisabled
@ -441,27 +404,12 @@ object PluginManager {
Log.i(TAG, "Plugin download done!")
}
@Throws
private fun assertNonRecursiveCallstack() {
if (Thread.currentThread().stackTrace.any { it.methodName == "loadPlugin" }) {
throw Error("You tried to call a function that will recursively call loadPlugin, this will cause crashes or memory leaks. Do not do this, there is better ways to implement the feature than reloading plugins. Are you sure you read the compile error or docs?")
}
}
/**
* Use updateAllOnlinePluginsAndLoadThem
*
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
assertNonRecursiveCallstack()
* */
fun loadAllOnlinePlugins(context: Context) {
// Load all plugins as fast as possible!
(getPluginsOnline()).toList().amap { pluginData ->
(getPluginsOnline()).toList().apmap { pluginData ->
loadPlugin(
context,
File(pluginData.filePath),
@ -472,37 +420,21 @@ object PluginManager {
/**
* Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb
*
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
assertNonRecursiveCallstack()
**/
fun hotReloadAllLocalPlugins(activity: FragmentActivity?) {
Log.d(TAG, "Reloading all local plugins!")
if (activity == null) return
getPluginsLocal().forEach {
unloadPlugin(it.filePath)
}
___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(activity, true)
loadAllLocalPlugins(activity, true)
}
/**
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
* and reload all pages even if they are previously valid
*
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
assertNonRecursiveCallstack()
**/
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
val dir = File(LOCAL_PLUGINS_PATH)
if (!dir.exists()) {
@ -516,64 +448,24 @@ object PluginManager {
val sortedPlugins = dir.listFiles()
// Always sort plugins alphabetically for reproducible results
Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: ${sortedPlugins?.size}")
Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins")
// Use app-specific external files directory and copy the file there.
// We have to do this because on Android 14+, it otherwise gives SecurityException
// due to dex files and setReadOnly seems to have no effect unless it it here.
val pluginDirectory = File(context.getExternalFilesDir(null), "plugins")
if (!pluginDirectory.exists()) {
pluginDirectory.mkdirs() // Ensure the plugins directory exists
}
// Make sure all local plugins are fully refreshed.
removeKey(PLUGINS_KEY_LOCAL)
sortedPlugins?.sortedBy { it.name }?.amap { file ->
try {
val destinationFile = File(pluginDirectory, file.name)
// Only copy the file if the destination file doesn't exist or if it
// has been modified (check file length and modification time).
if (!destinationFile.exists() ||
destinationFile.length() != file.length() ||
destinationFile.lastModified() != file.lastModified()
) {
// Copy the file to the app-specific plugin directory
file.copyTo(destinationFile, overwrite = true)
// After copying, set the destination file's modification time
// to match the source file. We do this for performance so that we
// can check the modification time and not make redundant writes.
destinationFile.setLastModified(file.lastModified())
}
// Load the plugin after it has been copied
maybeLoadPlugin(context, destinationFile)
} catch (t: Throwable) {
Log.e(TAG, "Failed to copy the file")
logError(t)
}
sortedPlugins?.sortedBy { it.name }?.apmap { file ->
maybeLoadPlugin(context, file)
}
loadedLocalPlugins = true
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
**/
fun checkSafeModeFile(): Boolean {
return safe {
return normalSafeApiCall {
val folder = File(CLOUD_STREAM_FOLDER)
if (!folder.exists()) return@safe false
if (!folder.exists()) return@normalSafeApiCall false
val files = folder.listFiles { _, name ->
name.equals("safe", ignoreCase = true)
}
@ -591,26 +483,26 @@ object PluginManager {
Log.i(TAG, "Loading plugin: $data")
return try {
// In case of Android 14+ then
// in case of android 14 then
try {
// Set the file as read-only and log if it fails
if (!file.setReadOnly()) {
Log.e(TAG, "Failed to set read-only on plugin file: ${file.name}")
}
File(filePath).setReadOnly()
} catch (t: Throwable) {
Log.e(TAG, "Failed to set dex as read-only")
Log.e(TAG, "Failed to set dex as readonly")
logError(t)
}
val loader = PathClassLoader(filePath, context.classLoader)
var manifest: BasePlugin.Manifest
var manifest: Plugin.Manifest
loader.getResourceAsStream("manifest.json").use { stream ->
if (stream == null) {
Log.e(TAG, "Failed to load plugin $fileName: No manifest found")
return false
}
InputStreamReader(stream).use { reader ->
manifest = parseJson<BasePlugin.Manifest>(reader.readText())
manifest = gson.fromJson(
reader,
Plugin.Manifest::class.java
)
}
}
@ -623,9 +515,9 @@ object PluginManager {
@Suppress("UNCHECKED_CAST")
val pluginClass: Class<*> =
loader.loadClass(manifest.pluginClassName) as Class<out BasePlugin?>
val pluginInstance: BasePlugin =
pluginClass.getDeclaredConstructor().newInstance() as BasePlugin
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
val pluginInstance: Plugin =
pluginClass.getDeclaredConstructor().newInstance() as Plugin
// Sets with the proper version
setPluginData(data.copy(version = version))
@ -645,33 +537,23 @@ object PluginManager {
addAssetPath.invoke(assets, file.absolutePath)
@Suppress("DEPRECATION")
(pluginInstance as? Plugin)?.resources = Resources(
pluginInstance.resources = Resources(
assets,
context.resources.displayMetrics,
context.resources.configuration
)
}
synchronized(plugins) {
plugins[filePath] = pluginInstance
}
synchronized(classLoaders) {
classLoaders[loader] = pluginInstance
}
synchronized(urlPlugins) {
urlPlugins[data.url ?: filePath] = pluginInstance
}
if (pluginInstance is Plugin) {
pluginInstance.load(context)
} else {
pluginInstance.load()
}
plugins[filePath] = pluginInstance
classLoaders[loader] = pluginInstance
urlPlugins[data.url ?: filePath] = pluginInstance
pluginInstance.load(context)
Log.i(TAG, "Loaded plugin ${data.internalName} successfully")
currentlyLoading = null
true
} catch (e: Throwable) {
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
showToast(
// context.getActivity(), // we are not always on the main thread
context.getActivity(),
context.getString(R.string.plugin_load_fail).format(fileName),
Toast.LENGTH_LONG
)
@ -695,33 +577,25 @@ object PluginManager {
}
// remove all registered apis
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
removePluginMapping(it)
synchronized(APIHolder.apis) {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
removePluginMapping(it)
}
}
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
}
APIHolder.allProviders.withLock {
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
}
extractorApis.withLock {
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
}
classLoaders.values.removeIf { v -> v == plugin }
VideoClickActionHolder.allVideoClickActions.withLock {
VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename }
}
synchronized(classLoaders) {
classLoaders.values.removeIf { v -> v == plugin }
}
synchronized(plugins) {
plugins.remove(absolutePath)
}
synchronized(urlPlugins) {
urlPlugins.values.removeIf { v -> v == plugin }
}
plugins.remove(absolutePath)
urlPlugins.values.removeIf { v -> v == plugin }
}
/**
@ -751,27 +625,25 @@ 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, pluginHash, internalName, file, loadPlugin)
return downloadPlugin(activity, pluginUrl, 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(activity, pluginUrl, file, pluginHash) ?: return false
val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
val data = PluginData(
internalName,
@ -814,84 +686,6 @@ object PluginManager {
}
}
/**
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
assertNonRecursiveCallstack()
showToast(activity.getString(R.string.starting_plugin_update_manually), Toast.LENGTH_LONG)
___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity)
afterPluginsLoadedEvent.invoke(false)
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES
val onlinePlugins = urls.toList().amap {
getRepoPlugins(it.url)?.toList() ?: emptyList()
}.flatten().distinctBy { it.second.url }
val allPlugins = getPluginsOnline().flatMap { savedData ->
onlinePlugins
.filter { it.second.internalName == savedData.internalName }
.mapNotNull { onlineData ->
OnlinePluginData(savedData, onlineData).takeIf { it.validOnlineData(activity) }
}
}.distinctBy { it.onlineData.second.url }
val updatedPlugins = mutableListOf<String>()
allPlugins.amap { pluginData ->
if (pluginData.isDisabled) {
Log.e(
"PluginManager",
"Unloading disabled plugin: ${pluginData.onlineData.second.name}"
)
unloadPlugin(pluginData.savedData.filePath)
} else {
val existingFile = File(pluginData.savedData.filePath)
if (existingFile.exists()) existingFile.delete()
if (downloadPlugin(
activity,
pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
existingFile,
true
)
) {
updatedPlugins.add(pluginData.onlineData.second.name)
}
}
}.also {
main {
val message = if (updatedPlugins.isNotEmpty()) {
activity.getString(R.string.plugins_updated_manually, updatedPlugins.size)
} else {
activity.getString(R.string.no_plugins_updated_manually)
}
showToast(message, Toast.LENGTH_LONG)
val notificationText = UiText.StringResource(
R.string.plugins_updated_manually,
listOf(updatedPlugins.size)
)
createNotification(activity, notificationText, updatedPlugins)
}
}
loadedOnlinePlugins = true
afterPluginsLoadedEvent.invoke(false)
Log.i("PluginManager", "Plugin update done!")
}
private fun Context.createNotificationChannel() {
hasCreatedNotChanel = true
// Create the NotificationChannel, but only on API 26+ because

View file

@ -1,17 +1,16 @@
package com.lagradost.cloudstream3.plugins
import android.content.Context
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty
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.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeAsync
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName
import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
@ -19,19 +18,16 @@ 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.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.concurrent.atomic.AtomicInteger
import java.io.InputStream
import java.io.OutputStream
/**
* 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,
@ -65,12 +61,10 @@ 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<String>?,
// 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?,
)
@ -79,26 +73,7 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
}
private val GH_REGEX =
Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
/** Returns a SHA-256 string of the file content.
* Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/
@WorkerThread
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { fis ->
val buffer = ByteArray(8192)
var read = fis.read(buffer)
while (read != -1) {
digest.update(buffer, 0, read)
read = fis.read(buffer)
}
}
return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) }
}
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
fun convertRawGitUrl(url: String): String {
@ -119,12 +94,12 @@ object RepositoryManager {
else fixedUrl
}
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
safeAsync {
suspendSafeApiCall {
app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
it2.headers["Location"]?.let { url ->
if (url.startsWith("https://cutt.ly/404")) return@safeAsync null
if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null
return@safeAsync url
if (url.startsWith("https://cutt.ly/404")) return@suspendSafeApiCall null
if (url.removeSuffix("/") == "https://cutt.ly") return@suspendSafeApiCall null
return@suspendSafeApiCall url
}
}
}
@ -132,7 +107,7 @@ object RepositoryManager {
}
suspend fun parseRepository(url: String): Repository? {
return safeAsync {
return suspendSafeApiCall {
// Take manifestVersion and such into account later
app.get(convertRawGitUrl(url)).parsedSafe()
}
@ -163,52 +138,21 @@ object RepositoryManager {
}.flatten()
}
suspend fun downloadPluginToFile(
context: Context,
pluginUrl: String,
file: File,
expectedFileHash: String?
file: File
): File? {
return safeAsync {
val parentDir = file.parentFile ?: return@safeAsync null
parentDir.mkdirs()
return suspendSafeApiCall {
file.mkdirs()
// Prevent corrupting the plugin file if the operation fails
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
// Overwrite if exists
if (file.exists()) {
file.delete()
}
file.createNewFile()
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
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
)
}
write(body.byteStream(), file.outputStream())
file
}
}
@ -247,7 +191,7 @@ object RepositoryManager {
// Unload all plugins, not using deletePlugin since we
// delete all data and files in deleteRepositoryData
safe {
normalSafeApiCall {
file.listFiles { plugin: File ->
unloadPlugin(plugin.absolutePath)
false
@ -256,4 +200,13 @@ 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)
}
}
}

View file

@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins
import android.util.Log
import android.widget.Toast
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.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R
import java.security.MessageDigest
import com.lagradost.cloudstream3.app
@ -12,76 +12,87 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
object VotingApi {
object VotingApi { // please do not cheat the votes lol
private const val LOGKEY = "VotingApi"
private const val API_DOMAIN = "https://api.countify.xyz"
private fun transformUrl(url: String): String =
private const val API_DOMAIN = "https://counterapi.com/api"
private fun transformUrl(url: String): String = // dont touch or all votes get reset
MessageDigest
.getInstance("SHA-256")
.digest("${url}#funny-salt".toByteArray())
.fold("") { str, it -> str + "%02x".format(it) }
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)
suspend fun SitePlugin.getVotes(): Int {
return getVotes(url)
}
fun SitePlugin.hasVoted(): Boolean {
return hasVoted(url)
}
suspend fun SitePlugin.vote(): Int {
return vote(url)
}
fun SitePlugin.canVote(): Boolean {
return canVote(this.url)
}
// Plugin url to Int
private val votesCache = mutableMapOf<String, Int>()
private fun getRepository(pluginUrl: String) = pluginUrl
.split("/")
.drop(2)
.take(3)
.joinToString("-")
private suspend fun readVote(pluginUrl: String): Int {
val id = transformUrl(pluginUrl)
val url = "$API_DOMAIN/get-total/$id"
Log.d(LOGKEY, "Requesting GET: $url")
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
Log.d(LOGKEY, "Requesting: $url")
return app.get(url).parsedSafe<Result>()?.value ?: 0
}
private suspend fun writeVote(pluginUrl: String): Boolean {
val id = transformUrl(pluginUrl)
val url = "$API_DOMAIN/increment/$id"
Log.d(LOGKEY, "Requesting POST: $url")
return app.post(url, emptyMap<String, String>())
.parsedSafe<CountifyResult>()?.count != null
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
Log.d(LOGKEY, "Requesting: $url")
return app.get(url).parsedSafe<Result>()?.value != 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 =
PluginManager.urlPlugins.contains(pluginUrl)
fun canVote(pluginUrl: String): Boolean {
return 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
@ -91,8 +102,7 @@ object VotingApi {
}
}
private data class CountifyResult(
val id: String? = null,
val count: Int? = null
private data class Result(
val value: Int?
)
}
}

View file

@ -1,8 +1,6 @@
package com.lagradost.cloudstream3.services
import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build.VERSION.SDK_INT
import androidx.core.app.NotificationCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
@ -84,11 +82,12 @@ class BackupWorkManager(val context: Context, workerParams: WorkerParameters) :
BACKUP_CHANNEL_DESCRIPTION
)
val foregroundInfo = if (SDK_INT >= 29)
setForeground(
ForegroundInfo(
BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC
) else ForegroundInfo(BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build())
setForeground(foregroundInfo)
BACKUP_NOTIFICATION_ID,
backupNotificationBuilder.build()
)
)
BackupUtils.backup(context)

View file

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

View file

@ -1,12 +1,12 @@
package com.lagradost.cloudstream3.services
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build.VERSION.SDK_INT
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import androidx.work.*
import com.lagradost.cloudstream3.*
@ -14,7 +14,7 @@ import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
@ -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.downloader.DownloadUtils.getImageBitmapFromUrl
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit
@ -75,7 +75,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
.setContentTitle(context.getString(R.string.subscription_in_progress_notification))
.setSmallIcon(com.google.android.gms.cast.framework.R.drawable.quantum_ic_refresh_white_24)
.setSmallIcon(R.drawable.quantum_ic_refresh_white_24)
.setProgress(0, 0, true)
private val updateNotificationBuilder =
@ -98,6 +98,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
)
}
@SuppressLint("UnspecifiedImmutableFlag")
override suspend fun doWork(): Result {
try {
// println("Update subscriptions!")
@ -107,13 +108,12 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
SUBSCRIPTION_CHANNEL_DESCRIPTION
)
val foregroundInfo = if (SDK_INT >= 29)
setForeground(
ForegroundInfo(
SUBSCRIPTION_NOTIFICATION_ID,
progressNotificationBuilder.build(),
FOREGROUND_SERVICE_TYPE_DATA_SYNC
) else ForegroundInfo(SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder.build(),)
setForeground(foregroundInfo)
progressNotificationBuilder.build()
)
)
val subscriptions = getAllSubscriptions()
@ -128,18 +128,18 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
updateProgress(max, progress, true)
// We need all plugins loaded.
PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context)
PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context, false)
PluginManager.loadAllOnlinePlugins(context)
PluginManager.loadAllLocalPlugins(context, false)
subscriptions.amap { savedData ->
subscriptions.apmap { savedData ->
try {
val id = savedData.id ?: return@amap null
val api = getApiFromNameNull(savedData.apiName) ?: return@amap null
val id = savedData.id ?: return@apmap null
val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
// Reasonable timeout to prevent having this worker run forever.
val response = withTimeoutOrNull(60_000) {
api.load(savedData.url) as? EpisodeResponse
} ?: return@amap null
} ?: return@apmap null
val dubPreference =
getDub(id) ?: if (
@ -183,10 +183,19 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
val intent = Intent(context, MainActivity::class.java).apply {
data = savedData.url.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}.putExtra(MainActivity.API_NAME_EXTRA_KEY, api.name)
}
val pendingIntent =
PendingIntentCompat.getActivity(context, 0, intent, 0, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getActivity(context, 0, intent, 0)
}
val poster = ioWork {
savedData.posterUrl?.let { url ->

View file

@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.services
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.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)
@ -43,3 +42,19 @@ 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))
// }
// }
// }
//}

View file

@ -1,8 +1,12 @@
package com.lagradost.cloudstream3.subtitles
import androidx.annotation.WorkerThread
import androidx.core.net.toUri
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.app
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 okio.BufferedSource
import okio.buffer
@ -11,6 +15,32 @@ import okio.source
import java.io.File
import java.util.zip.ZipInputStream
interface AbstractSubProvider {
val idPrefix: String
@WorkerThread
suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? {
throw NotImplementedError()
}
@WorkerThread
suspend fun load(data: SubtitleEntity): String? {
throw NotImplementedError()
}
@WorkerThread
suspend fun SubtitleResource.getResources(data: SubtitleEntity) {
this.addUrl(load(data))
}
@WorkerThread
suspend fun getResource(data: SubtitleEntity): SubtitleResource {
return SubtitleResource().apply {
this.getResources(data)
}
}
}
/**
* A builder for subtitle files.
* @see addUrl
@ -91,3 +121,4 @@ class SubtitleResource {
}
}
interface AbstractSubApi : AbstractSubProvider, AuthAPI

View file

@ -1,165 +1,149 @@
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<String, Array<AuthData>>
var cachedAccountIds: MutableMap<String, Int>
const val ACCOUNT_TOKEN = "auth_tokens"
const val ACCOUNT_IDS = "auth_ids"
fun accounts(prefix: String): Array<AuthData> {
require(prefix != "NONE")
return getKey<Array<AuthData>>(
ACCOUNT_TOKEN,
"${prefix}/${DataStoreHelper.currentAccount}"
) ?: arrayOf()
}
fun updateAccounts(prefix: String, array: Array<AuthData>) {
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<String, Int>()
for (api in allApis) {
ids.put(
api.idPrefix,
getKey<Int>(
ACCOUNT_IDS,
"${api.idPrefix}/${DataStoreHelper.currentAccount}",
NONE_ID
) ?: NONE_ID
)
}
synchronized(cachedAccountIds) {
cachedAccountIds = ids
}
}
init {
val data = mutableMapOf<String, Array<AuthData>>()
val ids = mutableMapOf<String, Int>()
for (api in allApis) {
data.put(api.idPrefix, accounts(api.idPrefix))
ids.put(
api.idPrefix,
getKey<Int>(
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"
}
}
}
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<OAuth2API>(
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<InAppAuthAPIManager>(
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)
}
}

View file

@ -1,280 +1,23 @@
package com.lagradost.cloudstream3.syncproviders
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.DataStoreHelper
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.txt
import java.net.URL
import java.security.SecureRandom
import java.util.Date
import java.util.concurrent.TimeUnit
interface AuthAPI {
val name: String
val icon: Int?
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 requiresLogin: Boolean
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
val createAccountUrl : String?
fun isRefreshTokenExpired(marginSec: Long = 10L) =
refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime
}
// don't change this as all keys depend on it
val idPrefix: String
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<String, String>? = null
)
// if this returns null then you are not logged in
fun loginInfo(): LoginInfo?
fun logOut()
/**
* 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<String, String> {
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,
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,194 +1,170 @@
package com.lagradost.cloudstream3.syncproviders
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.Levenshtein
import com.lagradost.cloudstream3.utils.UiText
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> = 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<SyncSearchResult>? =
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<String, String>? = 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<String>? = null,
var genres: List<String>? = null,
var synonyms: List<String>? = null,
var trailers: List<String>? = 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<SyncSearchResult>? = null,
var nextSeason: SyncSearchResult? = null,
var prevSeason: SyncSearchResult? = null,
var actors: List<ActorData>? = null,
)
data class Page(
val title: UiText, var items: List<LibraryItem>
) {
fun sort(method: ListSorting?, query: String? = null) {
items = when (method) {
ListSorting.Query ->
if (query != null) {
items.sortedBy {
-Levenshtein.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<LibraryList>,
val supportedListSorting: Set<ListSorting>
)
data class LibraryList(
val name: UiText,
val items: List<LibraryItem>
)
data class LibraryItem(
override val name: String,
override val url: String,
/**
* Unique unchanging string used for data storage.
* This should be the actual id when you change scores and status
* since score changes from library might get added in the future.
**/
val syncId: String,
val episodesCompleted: Int?,
val episodesTotal: Int?,
val personalRating: Score?,
val lastUpdatedUnixTime: Long?,
override val apiName: String,
override var type: TvType?,
override var posterUrl: String?,
override var posterHeaders: Map<String, String>?,
override var quality: SearchQuality?,
val releaseDate: Date?,
override var id: Int? = null,
val plot: String? = null,
override var score: Score? = null,
val tags: List<String>? = null
) : SearchResponse
}
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.ui.result.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<SyncSearchResult>?
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<String, String>? = 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<String>? = null,
var genres: List<String>? = null,
var synonyms: List<String>? = null,
var trailers: List<String>? = 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<SyncSearchResult>? = null,
var nextSeason: SyncSearchResult? = null,
var prevSeason: SyncSearchResult? = null,
var actors: List<ActorData>? = null,
)
data class Page(
val title: UiText, var items: List<LibraryItem>
) {
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<LibraryList>,
val supportedListSorting: Set<ListSorting>
)
data class LibraryList(
val name: UiText,
val items: List<LibraryItem>
)
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<String, String>?,
override var quality: SearchQuality?,
val releaseDate: Date?,
override var id: Int? = null,
val plot : String? = null,
val rating: Int? = null,
val tags: List<String>? = null
) : SearchResponse
}

View file

@ -1,30 +1,48 @@
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<Boolean> =
runCatching {
val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus)
requireLibraryRefresh = true
status
}
suspend fun status(id: String): Result<SyncAPI.AbstractSyncStatus?> = runCatching {
api.status(freshAuth(), id)
}
suspend fun load(id: String): Result<SyncAPI.SyncResult?> = runCatching {
api.load(freshAuth(), id)
}
suspend fun library(): Result<SyncAPI.LibraryMetadata?> = runCatching {
api.library(freshAuth())
}
}
package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
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<Boolean> {
return safeApiCall { repo.score(id, status) }
}
suspend fun getStatus(id: String): Resource<SyncAPI.AbstractSyncStatus> {
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
}
suspend fun getResult(id: String): Resource<SyncAPI.SyncResult> {
return safeApiCall { repo.getResult(id) ?: throw ErrorLoadingException("No data") }
}
suspend fun search(query: String): Resource<List<SyncAPI.SyncSearchResult>> {
return safeApiCall { repo.search(query) ?: throw ErrorLoadingException() }
}
suspend fun getPersonalLibrary(): Resource<SyncAPI.LibraryMetadata> {
return safeApiCall { repo.getPersonalLibrary() ?: throw ErrorLoadingException() }
}
fun hasAccount(): Boolean {
return normalSafeApiCall { repo.loginInfo() != null } ?: false
}
fun getIdFromUrl(url: String): String? = normalSafeApiCall {
repo.getIdFromUrl(url)
}
}

View file

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

View file

@ -1,91 +1,93 @@
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.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.*
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.mvvm.logError
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.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
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.ui.result.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 com.lagradost.cloudstream3.utils.txt
import java.net.URL
import java.net.URLEncoder
import java.util.Locale
class AniListApi : SyncAPI() {
class AniListApi(index: Int) : AccountManager(index), 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 hasOAuth2 = true
override val supportDeviceAuth = false
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 loginRequest(): AuthLoginPage? =
AuthLoginPage("https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token")
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 loginInfo(): AuthAPI.LoginInfo? {
// context.getUser(true)?.
getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.let { user ->
return AuthAPI.LoginInfo(
profilePicture = user.picture,
name = user.name,
accountIndex = accountIndex
)
}
return null
}
// 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 fun logOut() {
requireLibraryRefresh = true
removeAccountKeys()
}
override suspend fun user(token: AuthToken?): AuthUser? {
val user = getUser(token ?: return null)
?: throw ErrorLoadingException("Unable to fetch user data")
return AuthUser(
id = user.id,
name = user.name,
profilePicture = user.picture,
)
override fun authenticate(activity: FragmentActivity?) {
val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token"
openBrowser(request, activity)
}
override fun urlToId(url: String): String? =
url.removePrefix("$mainUrl/anime/").removeSuffix("/")
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()
switchToNewAccount()
setKey(accountId, ANILIST_UNIXTIME_KEY, endTime)
setKey(accountId, ANILIST_TOKEN_KEY, token)
val user = getUser()
requireLibraryRefresh = true
return user != null
}
override fun getIdFromUrl(url: String): String {
return url.removePrefix("$mainUrl/anime/").removeSuffix("/")
}
private fun getUrlFromId(id: Int): String {
return "$mainUrl/anime/$id"
}
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
val data = searchShows(query) ?: return null
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
val data = searchShows(name) ?: return null
return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult(
it.title.romaji ?: return null,
@ -97,7 +99,7 @@ class AniListApi : SyncAPI() {
}
}
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
override suspend fun getResult(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
@ -139,11 +141,11 @@ class AniListApi : SyncAPI() {
}
)
},
publicScore = Score.from100(season.averageScore),
publicScore = season.averageScore?.times(100),
recommendations = season.recommendations?.edges?.mapNotNull { rec ->
val recMedia = rec.node.mediaRecommendation
SyncAPI.SyncSearchResult(
name = recMedia?.title?.userPreferred ?: return@mapNotNull null,
name = recMedia.title?.userPreferred ?: return@mapNotNull null,
this.name,
recMedia.id?.toString() ?: return@mapNotNull null,
getUrlFromId(recMedia.id),
@ -159,12 +161,12 @@ class AniListApi : SyncAPI() {
)
}
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
val data = getDataAboutId(internalId) ?: return null
return SyncAPI.SyncStatus(
score = Score.from100(data.score),
score = data.score,
watchedEpisodes = data.progress,
status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
isFavorite = data.isFavourite,
@ -172,25 +174,24 @@ class AniListApi : SyncAPI() {
)
}
override suspend fun updateStatus(
auth: AuthData?,
id: String,
newStatus: AbstractSyncStatus
): Boolean {
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return postDataAboutId(
auth ?: return false,
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(newStatus.status.internalId),
newStatus.score,
newStatus.watchedEpisodes
)
fromIntToAnimeStatus(status.status.internalId),
status.score,
status.watchedEpisodes
).also {
requireLibraryRefresh = requireLibraryRefresh || it
}
}
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 {
@ -460,7 +461,21 @@ class AniListApi : SyncAPI() {
}
}
private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? {
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? {
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)
@ -470,7 +485,7 @@ class AniListApi : SyncAPI() {
mediaListEntry {
progress
status
score (format: POINT_100)
score (format: POINT_10)
}
title {
english
@ -479,7 +494,7 @@ class AniListApi : SyncAPI() {
}
}"""
val data = postApi(auth.token, q, true)
val data = postApi(q, true)
val d = parseJson<GetDataRoot>(data ?: return null)
val main = d.data?.media
@ -507,24 +522,37 @@ class AniListApi : SyncAPI() {
}
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 fun getAuth(): String? {
return getKey(
accountId,
ANILIST_TOKEN_KEY
)
}
private suspend fun postApi(q: String, cache: Boolean = false): String? {
return suspendSafeApiCall {
if (!checkToken()) {
app.post(
"https://graphql.anilist.co/",
headers = mapOf(
"Authorization" to "Bearer " + (getAuth()
?: return@suspendSafeApiCall 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,
@ -596,7 +624,7 @@ class AniListApi : SyncAPI() {
this.media.id.toString(),
this.progress,
this.media.episodes,
Score.from100(this.score),
this.score,
this.updatedAt.toLong(),
"AniList",
TvType.Anime,
@ -624,23 +652,27 @@ class AniListApi : SyncAPI() {
@JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
)
private suspend fun getAniListAnimeListSmart(auth: AuthData): Array<Lists>? {
private fun getAniListListCached(): Array<Lists>? {
return getKey(ANILIST_CACHED_LIST) as? Array<Lists>
}
private suspend fun getAniListAnimeListSmart(): Array<Lists>? {
if (getAuth() == null) return null
if (checkToken()) return null
return if (requireLibraryRefresh) {
val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray()
val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
if (list != null) {
setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list)
setKey(ANILIST_CACHED_LIST, list)
}
list
} else {
getKey<Array<Lists>>(
ANILIST_CACHED_LIST,
auth.user.id.toString()
) as? Array<Lists>
getAniListListCached()
}
}
override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
val list = getAniListAnimeListSmart()?.groupBy {
convertAniListStringToStatus(it.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten()
@ -667,8 +699,10 @@ class AniListApi : SyncAPI() {
)
}
private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? {
val userID = auth.user.id
private suspend fun getFullAniListList(): FullAnilistList? {
/** WARNING ASSUMES ONE USER! **/
val userID = getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.id ?: return null
val mediaType = "ANIME"
val query = """
@ -711,11 +745,11 @@ class AniListApi : SyncAPI() {
}
}
"""
val text = postApi(auth.token, query)
val text = postApi(query)
return text?.toKotlinObject()
}
suspend fun toggleLike(auth: AuthData, id: Int): Boolean {
suspend fun toggleLike(id: Int): Boolean {
val q = """mutation (${'$'}animeId: Int = $id) {
ToggleFavourite (animeId: ${'$'}animeId) {
anime {
@ -728,7 +762,7 @@ class AniListApi : SyncAPI() {
}
}
}"""
val data = postApi(auth.token, q)
val data = postApi(q)
return data != ""
}
@ -738,17 +772,15 @@ class AniListApi : SyncAPI() {
data class MediaListId(@JsonProperty("id") val id: Long? = null)
private suspend fun postDataAboutId(
auth: AuthData,
id: Int,
type: AniListStatusType,
score: Score?,
score: Int?,
progress: Int?
): Boolean {
val userID = auth.user.id
val q =
// Delete item if status type is None
if (type == AniListStatusType.None) {
val userID = getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.id ?: return false
// Get list ID for deletion
val idQuery = """
query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) {
@ -757,7 +789,7 @@ class AniListApi : SyncAPI() {
}
}
"""
val response = postApi(auth.token, idQuery)
val response = postApi(idQuery)
val listId =
tryParseJson<MediaListItemRoot>(response)?.data?.mediaList?.id ?: return false
"""
@ -773,7 +805,7 @@ class AniListApi : SyncAPI() {
0,
type.value
)]
}, ${if (score != null) "${'$'}scoreRaw: Int = ${score.toInt(100)}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
}, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
id
status
@ -783,11 +815,11 @@ class AniListApi : SyncAPI() {
}"""
}
val data = postApi(auth.token, q)
val data = postApi(q)
return data != ""
}
private suspend fun getUser(token: AuthToken): AniListUser? {
private suspend fun getUser(setSettings: Boolean = true): AniListUser? {
val q = """
{
Viewer {
@ -805,15 +837,23 @@ class AniListApi : SyncAPI() {
}
}
}"""
val data = postApi(token, q)
val data = postApi(q)
if (data.isNullOrBlank()) return null
val userData = parseJson<AniListRoot>(data)
val u = userData.data?.viewer ?: return null
val u = userData.data?.viewer
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
}
@ -877,8 +917,7 @@ class AniListApi : SyncAPI() {
)
data class Recommendation(
val id: Long,
@JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia?,
@JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia,
)
data class CharacterName(
@ -1008,8 +1047,8 @@ class AniListApi : 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?,
)
@ -1023,8 +1062,8 @@ class AniListApi : 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?,
)

View file

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

View file

@ -1,676 +1,8 @@
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.LocalDate
import java.time.ZoneId
import java.util.Date
import java.util.Locale
const val KITSU_MAX_SEARCH_LIMIT = 20
class KitsuApi: SyncAPI() {
override var name = "Kitsu"
override val idPrefix = "kitsu"
private val apiUrl = "https://kitsu.io/api/edge"
private val fallbackApiUrl = "https://kitsu.app/api/edge"
private val oauthUrl = "https://kitsu.io/api/oauth"
private val fallbackOauthUrl = "https://kitsu.app/api/oauth"
override val hasInApp = true
override val mainUrl = "https://kitsu.app"
override val icon = R.drawable.kitsu_icon
override val syncIdName = SyncIdName.Kitsu
override val createAccountUrl = mainUrl
override val supportedWatchTypes = setOf(
SyncWatchType.WATCHING,
SyncWatchType.COMPLETED,
SyncWatchType.PLANTOWATCH,
SyncWatchType.DROPPED,
SyncWatchType.ONHOLD,
SyncWatchType.NONE
)
override val inAppLoginRequirement = AuthLoginRequirement(
password = true,
email = true
)
private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
try {
val response = chain.proceed(request);
if (response.isSuccessful) return response
response.close()
} catch (_: Exception) {
}
val fallbackRequest: Request = request.newBuilder()
.url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl))
.build()
return chain.proceed(fallbackRequest)
}
}
private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl)
private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl)
override suspend fun login(form: AuthLoginResponse): AuthToken? {
val username = form.email ?: return null
val password = form.password ?: return null
val grantType = "password"
val token = app.post(
"$oauthUrl/token",
data = mapOf(
"grant_type" to grantType,
"username" to username,
"password" to password
),
interceptor = oauthFallbackInterceptor
).parsed<ResponseToken>()
return AuthToken(
accessTokenLifetime = unixTime + token.expiresIn.toLong(),
refreshToken = token.refreshToken,
accessToken = token.accessToken,
)
}
override suspend fun refreshToken(token: AuthToken): AuthToken {
val res = app.post(
"$oauthUrl/token",
data = mapOf(
"grant_type" to "refresh_token",
"refresh_token" to token.refreshToken!!
),
interceptor = oauthFallbackInterceptor
).parsed<ResponseToken>()
return AuthToken(
accessToken = res.accessToken,
refreshToken = res.refreshToken,
accessTokenLifetime = unixTime + res.expiresIn.toLong()
)
}
override suspend fun user(token: AuthToken?): AuthUser? {
val user = app.get(
"$apiUrl/users?filter[self]=true",
headers = mapOf(
"Authorization" to "Bearer ${token?.accessToken ?: return null}"
), cacheTime = 0,
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
if (user.data.isEmpty()) {
return null
}
return AuthUser(
id = user.data[0].id.toInt(),
name = user.data[0].attributes.name,
profilePicture = user.data[0].attributes.avatar?.original
)
}
override suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? {
val auth = auth?.token?.accessToken ?: return null
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount")
val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth",
), cacheTime = 0,
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
return res.data.map {
val attributes = it.attributes
val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title"
SyncSearchResult(
title,
this.name,
it.id,
"$mainUrl/anime/${it.id}/",
attributes.posterImage?.large ?: attributes.posterImage?.medium
)
}
}
override suspend fun load(auth : AuthData?, id: String): SyncResult? {
val auth = auth?.token?.accessToken ?: return null
if (id.toIntOrNull() == null) {
return null
}
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<KitsuResponse>().data.attributes
return SyncResult(
id = id,
totalEpisodes = anime.episodeCount,
title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(),
publicScore = Score.from(anime.ratingTwenty, 20),
duration = anime.episodeLength,
synopsis = anime.synopsis,
airStatus = when(anime.status) {
"finished" -> ShowStatus.Completed
"current" -> ShowStatus.Ongoing
else -> null
},
nextAiring = null,
studio = null,
genres = null,
trailers = null,
startDate = LocalDate.parse(anime.startDate).toEpochDay(),
endDate = LocalDate.parse(anime.endDate).toEpochDay(),
recommendations = null,
nextSeason =null,
prevSeason = null,
actors = null,
)
}
override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? {
val accessToken = auth?.token?.accessToken ?: return null
val userId = auth.user.id
val selectedFields = arrayOf("status","ratingTwenty", "progress")
val url =
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}"
val anime = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $accessToken"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.firstOrNull()?.attributes
if (anime == null) {
return SyncStatus(
score = null,
status = SyncWatchType.NONE,
isFavorite = null,
watchedEpisodes = null
)
}
return SyncStatus(
score = Score.from(anime.ratingTwenty, 20),
status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)),
isFavorite = null,
watchedEpisodes = anime.progress,
)
}
suspend fun getAnimeIdByTitle(title: String): String? {
val animeSelectedFields = arrayOf("titles","canonicalTitle")
val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
val res = app.get(url, interceptor = apiFallbackInterceptor).parsed<KitsuResponse>()
return res.data.firstOrNull()?.id
}
override fun urlToId(url: String): String? =
Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first()
override suspend fun updateStatus(
auth : AuthData?,
id: String,
newStatus: AbstractSyncStatus
): Boolean {
return setScoreRequest(
auth ?: return false,
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(newStatus.status),
newStatus.score?.toInt(20),
newStatus.watchedEpisodes
)
}
private suspend fun setScoreRequest(
auth : AuthData,
id: Int,
status: KitsuStatusType? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val libraryEntryId = getAnimeLibraryEntryId(auth, id)
// Exists entry for anime in library
if (libraryEntryId != null) {
// Delete anime from library
if (status == null || status == KitsuStatusType.None) {
val res = app.delete(
"$apiUrl/library-entries/$libraryEntryId",
headers = mapOf(
"Authorization" to "Bearer ${auth.token.accessToken}"
),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
return setScoreRequest(
auth,
libraryEntryId,
kitsuStatusAsString[maxOf(0, status.value)],
score,
numWatchedEpisodes
)
}
val data = mapOf(
"data" to mapOf(
"type" to "libraryEntries",
"attributes" to mapOf(
"ratingTwenty" to score,
"progress" to numWatchedEpisodes,
"status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)],
),
"relationships" to mapOf(
"anime" to mapOf(
"data" to mapOf(
"type" to "anime",
"id" to id.toString()
)
),
"user" to mapOf(
"data" to mapOf(
"type" to "users",
"id" to auth.user.id
)
)
)
)
)
val res = app.post(
"$apiUrl/library-entries",
headers = mapOf(
"content-type" to "application/vnd.api+json",
"Authorization" to "Bearer ${auth.token.accessToken}"
),
requestBody = data.toJson().toRequestBody(),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
@Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest(
auth : AuthData,
id: Int,
status: String? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val data = mapOf(
"data" to mapOf(
"type" to "libraryEntries",
"id" to id.toString(),
"attributes" to mapOf(
"ratingTwenty" to score,
"progress" to numWatchedEpisodes,
"status" to status
)
)
)
val res = app.patch(
"$apiUrl/library-entries/$id",
headers = mapOf(
"content-type" to "application/vnd.api+json",
"Authorization" to "Bearer ${auth.token.accessToken}"
),
requestBody = data.toJson().toRequestBody(),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? {
val userId = auth.user.id
val res = app.get(
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id",
headers = mapOf(
"Authorization" to "Bearer ${auth.token.accessToken}"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.firstOrNull() ?: return null
return res.id.toInt()
}
override suspend fun library(auth : AuthData?): LibraryMetadata? {
val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy {
convertToStatus(it.attributes.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
} ?: emptyMap()
// To fill empty lists when Kitsu does not return them
val baseMap =
KitsuStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<LibraryItem>()
}
return LibraryMetadata(
(baseMap + list).map { LibraryList(txt(it.key), it.value) },
setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
)
}
private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array<KitsuNode>? {
return if (requireLibraryRefresh) {
val list = getKitsuAnimeList(auth.token, auth.user.id)
setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list)
list
} else {
getKey<Array<KitsuNode>>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array<KitsuNode>
}
}
private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array<KitsuNode> {
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount")
val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status")
val limit = 500
var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}"
val fullList = mutableListOf<KitsuNode>()
while (true) {
val data: KitsuResponse = getKitsuAnimeListSlice(token, url)
data.data.forEachIndexed { index, value ->
value.anime = data.included?.get(index)
}
fullList.addAll(data.data)
url = data.links?.next ?: break
}
return fullList.toTypedArray()
}
private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse {
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer ${token.accessToken}",
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
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, 20),
parseDateLong(this.attributes.updatedAt),
"Kitsu",
TvType.Anime,
posterImage?.large ?: posterImage?.medium,
null,
null,
plot = synopsis,
releaseDate = if (startDate == null) null else try {
Date.from(LocalDate.parse(startDate).atStartOfDay()
.atZone(ZoneId.systemDefault())
.toInstant())
} catch (_: RuntimeException) {
null
}
)
}
}
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: Int?,
@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<KitsuNode>,
/* When requesting related info (User library entry -> anime) */
@JsonProperty("included") val included: List<KitsuAnimeData>?,
)
companion object {
const val KITSU_CACHED_LIST: String = "kitsu_cached_list"
private fun parseDateLong(string: String?): Long? {
return try {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(
string ?: return null
)?.time?.div(1000)
} catch (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
@ -810,4 +142,4 @@ query {
val canonical: String? = null
)
}
}
}

View file

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

View file

@ -1,112 +1,87 @@
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.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
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.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.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
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.ui.result.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 com.lagradost.cloudstream3.utils.txt
import java.net.URL
import java.security.SecureRandom
import java.text.ParseException
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 : SyncAPI() {
class MALApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "MAL"
override val key = "1714d6f2f4f7cc19644384f8c4629910"
override val redirectUrl = "mallogin"
override val idPrefix = "mal"
val key = "1714d6f2f4f7cc19644384f8c4629910"
override var mainUrl = "https://myanimelist.net"
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 val supportedWatchTypes = setOf(
SyncWatchType.WATCHING,
SyncWatchType.COMPLETED,
SyncWatchType.PLANTOWATCH,
SyncWatchType.DROPPED,
SyncWatchType.ONHOLD,
SyncWatchType.NONE
)
override fun logOut() {
requireLibraryRefresh = true
removeAccountKeys()
}
data class PayLoad(
val requestId: Int,
val codeVerifier: String
)
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
val payloadData = parseJson<PayLoad>(payload!!)
val sanitizer = splitRedirectUrl(redirectUrl)
val state = sanitizer["state"]!!
if (state != "RequestID${payloadData.requestId}") {
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"
override fun loginInfo(): AuthAPI.LoginInfo? {
getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
return AuthAPI.LoginInfo(
profilePicture = user.picture,
name = user.name,
accountIndex = accountIndex
)
).parsed<ResponseToken>()
return AuthToken(
accessTokenLifetime = unixTime + token.expiresIn.toLong(),
refreshToken = token.refreshToken,
accessToken = token.accessToken
}
return null
}
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<MalUser>()
return AuthUser(
id = user.id,
name = user.name,
profilePicture = user.picture
)
}
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
val auth = auth?.token?.accessToken ?: return null
val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT"
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult> {
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
).parsed<MalSearch>()
return res.data.map {
).text
return parseJson<MalSearch>(res).data.map {
val node = it.node
SyncAPI.SyncSearchResult(
node.title,
@ -118,21 +93,19 @@ class MALApi : SyncAPI() {
}
}
override fun urlToId(url: String): String? =
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
override fun getIdFromUrl(url: String): String {
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
}
override suspend fun updateStatus(
auth: AuthData?,
id: String,
newStatus: SyncAPI.AbstractSyncStatus
): Boolean {
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return setScoreRequest(
auth?.token ?: return false,
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(newStatus.status),
newStatus.score?.toInt(10),
newStatus.watchedEpisodes
)
fromIntToAnimeStatus(status.status.internalId),
status.score,
status.watchedEpisodes
).also {
requireLibraryRefresh = requireLibraryRefresh || it
}
}
data class MalAnime(
@ -225,14 +198,14 @@ class MALApi : SyncAPI() {
)
}
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
val auth = auth?.token?.accessToken ?: return null
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
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 = mapOf(
url, headers = if (auth == null) emptyMap() else mapOf(
"Authorization" to "Bearer $auth"
)
).text
@ -241,7 +214,7 @@ class MALApi : SyncAPI() {
id = internalId.toString(),
totalEpisodes = malAnime.numEpisodes,
title = malAnime.title,
publicScore = Score.from10(malAnime.mean),
publicScore = malAnime.mean?.toFloat()?.times(1000)?.toInt(),
duration = malAnime.averageEpisodeDuration,
synopsis = malAnime.synopsis,
airStatus = when (malAnime.status) {
@ -271,20 +244,13 @@ class MALApi : SyncAPI() {
}
}
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<SmallMalAnime>().myListStatus
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val data =
getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status")
return SyncAPI.SyncStatus(
score = Score.from10(data?.score),
score = data?.score,
status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
isFavorite = null,
watchedEpisodes = data?.numEpisodesWatched,
@ -295,17 +261,14 @@ class MALApi : 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 when (string) {
"watching" -> MalStatusType.Watching
"completed" -> MalStatusType.Completed
"on_hold" -> MalStatusType.OnHold
"dropped" -> MalStatusType.Dropped
"plan_to_watch" -> MalStatusType.PlanToWatch
else -> MalStatusType.None
}
return fromIntToAnimeStatus(malStatusAsString.indexOf(string))
}
enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) {
@ -317,15 +280,16 @@ class MALApi : SyncAPI() {
None(-1, R.string.type_none)
}
private fun fromIntToAnimeStatus(inp: SyncWatchType): MalStatusType {//= AniListStatusType.values().first { it.value == inp }
private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp }
return when (inp) {
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
-1 -> MalStatusType.None
0 -> MalStatusType.Watching
1 -> MalStatusType.Completed
2 -> MalStatusType.OnHold
3 -> MalStatusType.Dropped
4 -> MalStatusType.PlanToWatch
5 -> MalStatusType.Watching
else -> MalStatusType.None
}
}
@ -340,38 +304,85 @@ class MALApi : SyncAPI() {
}
}
override fun loginRequest(): AuthLoginPage? {
val codeVerifier = generateCodeVerifier()
val requestId = ++requestIdCounter
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", "")
val codeChallenge = codeVerifier
val request =
"$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId"
return AuthLoginPage(
url = request,
payload = PayLoad(requestId, codeVerifier).toJson()
)
openBrowser(request, activity)
}
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<ResponseToken>()
private var requestId = 0
private var codeVerifier = ""
return AuthToken(
accessToken = res.accessToken,
refreshToken = res.refreshToken,
accessTokenLifetime = unixTime + res.expiresIn.toLong()
)
private fun storeToken(response: String) {
try {
if (response != "") {
val token = parseJson<ResponseToken>(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)
}
}
private var requestIdCounter = 0
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)
}
}
private val allTitles = hashMapOf<Int, MalTitleHolder>()
@ -430,7 +441,7 @@ class MALApi : SyncAPI() {
this.node.id.toString(),
this.listStatus?.numEpisodesWatched,
this.node.numEpisodes,
Score.from10(this.listStatus?.score),
this.listStatus?.score?.times(10),
parseDateLong(this.listStatus?.updatedAt),
"MAL",
TvType.Anime,
@ -438,16 +449,12 @@ class MALApi : 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}
)
}
}
@ -477,8 +484,23 @@ class MALApi : SyncAPI() {
@JsonProperty("start_time") val startTime: String?
)
override suspend fun library(auth: AuthData?): LibraryMetadata? {
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
private fun getMalAnimeListCached(): Array<Data>? {
return getKey(MAL_CACHED_LIST) as? Array<Data>
}
private suspend fun getMalAnimeListSmart(): Array<Data>? {
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 {
convertToStatus(it.listStatus?.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
@ -505,22 +527,13 @@ class MALApi : SyncAPI() {
)
}
private suspend fun getMalAnimeListSmart(auth: AuthData): Array<Data>? {
return if (requireLibraryRefresh) {
val list = getMalAnimeList(auth.token)
setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)
list
} else {
getKey<Array<Data>>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array<Data>
}
}
private suspend fun getMalAnimeList(token: AuthToken): Array<Data> {
private suspend fun getMalAnimeList(): Array<Data> {
checkMalToken()
var offset = 0
val fullList = mutableListOf<Data>()
val offsetRegex = Regex("""offset=(\d+)""")
while (true) {
val data: MalList = getMalAnimeListSlice(token, offset) ?: break
val data: MalList = getMalAnimeListSlice(offset) ?: break
fullList.addAll(data.data)
offset =
data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() }
@ -529,29 +542,128 @@ class MALApi : SyncAPI() {
return fullList.toTypedArray()
}
private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList? {
private suspend fun getMalAnimeListSlice(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 ${token.accessToken}",
"Authorization" to "Bearer $auth",
), 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<SmallMalAnime>(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<MalRoot>(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<MalUser>(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,
@ -574,7 +686,6 @@ class MALApi : SyncAPI() {
@Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest(
token: AuthToken,
id: Int,
status: String? = null,
score: Int? = null,
@ -589,7 +700,7 @@ class MALApi : SyncAPI() {
return app.put(
"$apiUrl/v2/anime/$id/my_list_status",
headers = mapOf(
"Authorization" to "Bearer ${token.accessToken}"
"Authorization" to "Bearer " + (getAuth() ?: return null)
),
data = data
).text

View file

@ -2,44 +2,56 @@ package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag
import okhttp3.Interceptor
import okhttp3.Response
class OpenSubtitlesApi : SubtitleAPI() {
override val name = "OpenSubtitles"
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
override val idPrefix = "opensubtitles"
override val name = "OpenSubtitles"
override val icon = R.drawable.open_subtitles_icon
override val hasInApp = true
override val inAppLoginRequirement = AuthLoginRequirement(
password = true,
username = true,
)
override val requiresPassword = true
override val requiresUsername = 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
const val userAgent = "Cloudstream3 v0.2"
val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY)
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.1"
override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(
chain.request().newBuilder()
.removeHeader("user-agent")
.addHeader("user-agent", userAgent)
.addHeader("Api-Key", API_KEY)
.build()
)
}
}
private fun canDoRequest(): Boolean {
@ -57,53 +69,121 @@ class OpenSubtitlesApi : SubtitleAPI() {
throw ErrorLoadingException("Too many requests")
}
override suspend fun refreshToken(token: AuthToken): AuthToken? {
return login(parseJson<AuthLoginResponse>(token.payload ?: return null))
private fun getAuthKey(): SubtitleOAuthEntity? {
return getKey(accountId, OPEN_SUBTITLES_USER_KEY)
}
override suspend fun user(token: AuthToken?): AuthUser? {
val user = parseJson<AuthLoginResponse>(token?.payload ?: return null)
val username = user.username ?: return null
return AuthUser(
id = username.hashCode(),
name = username
)
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 login(form: AuthLoginResponse): AuthToken? {
val username = form.username ?: return null
val password = form.password ?: 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)
}
/*
Authorize app to connect to API, using username/password.
Required to run at startup.
Returns OAuth entity with valid access token.
*/
override suspend fun initialize() {
currentSession = getAuthKey() ?: return // just in case the following fails
initLogin(currentSession?.user ?: return, currentSession?.pass ?: return)
}
override fun logOut() {
setAuthKey(null)
removeAccountKeys()
currentSession = getAuthKey()
}
private suspend fun initLogin(username: String, password: String): Boolean {
//Log.i(TAG, "DATA = [$username] [$password]")
val response = app.post(
url = "$HOST/login",
headers = mapOf(
"Content-Type" to "application/json",
) + headers,
json = mapOf(
),
data = mapOf(
"username" to username,
"password" to password
),
).parsed<OAuthToken>()
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()
interceptor = headerInterceptor
)
//Log.i(TAG, "Responsecode = ${response.code}")
//Log.i(TAG, "Result => ${response.text}")
if (response.isSuccessful) {
AppUtils.tryParseJson<OAuthToken>(response.text)?.let { token ->
setAuthKey(
SubtitleOAuthEntity(
user = username,
pass = password,
accessToken = token.token ?: run {
return false
})
)
}
return true
}
return false
}
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
val username = data.username ?: throw ErrorLoadingException("Requires Username")
val password = data.password ?: throw ErrorLoadingException("Requires Password")
switchToNewAccount()
try {
if (initLogin(username, password)) {
registerAccount()
return true
}
} catch (e: Exception) {
logError(e)
switchToOldAccount()
}
switchToOldAccount()
return false
}
/**
* Some languages do not use the normal country codes on OpenSubtitles
* */
private val languageExceptions = mapOf<String, String>(
// "pt" to "pt-PT",
// "pt" to "pt-BR"
)
private fun fixLanguage(language: String?): String? {
return languageExceptions[language] ?: language
}
// O(n) but good enough, BiMap did not want to work properly
private fun fixLanguageReverse(language: String?): String? {
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
}
/**
* Fetch subtitles using token authenticated on previous method (see authorize).
* Returns list of Subtitles which user can select to download (see load).
* */
override suspend fun search(
auth : AuthData?,
query: AbstractSubtitleEntities.SubtitleSearch
): List<AbstractSubtitleEntities.SubtitleEntity>? {
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
throwIfCantDoRequest()
val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: ""
val fixedLang = fixLanguage(query.lang)
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
val queryText = query.query
@ -116,17 +196,17 @@ class OpenSubtitlesApi : SubtitleAPI() {
val searchQueryUrl = when (imdbId > 0) {
//Use imdb_id to search if its valid
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
}
val req = app.get(
url = searchQueryUrl,
headers = mapOf(
Pair("Content-Type", "application/json")
) + headers,
),
interceptor = headerInterceptor
)
Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}")
Log.i(TAG, "Search Req => ${req.text}")
if (!req.isSuccessful) {
if (req.code == 429)
@ -147,7 +227,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
//Use any valid name/title in hierarchy
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
?: featureDetails?.parentTitle ?: attr.release ?: query.query
val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: ""
val lang = fixLanguageReverse(attr.language) ?: ""
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
val year = featureDetails?.year ?: query.year
@ -161,7 +241,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix,
name = name,
lang = langTagIETF,
lang = lang,
data = resultData,
type = type,
source = this.name,
@ -181,12 +261,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
Process data returned from search.
Returns string url for the subtitle file.
*/
override suspend fun load(
auth : AuthData?,
subtitle: AbstractSubtitleEntities.SubtitleEntity
): String? {
if(auth == null) return null
override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? {
throwIfCantDoRequest()
val req = app.post(
@ -194,14 +269,15 @@ class OpenSubtitlesApi : SubtitleAPI() {
headers = mapOf(
Pair(
"Authorization",
"Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
"Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
),
Pair("Content-Type", "application/json"),
Pair("Accept", "*/*")
) + headers,
),
data = mapOf(
Pair("file_id", subtitle.data)
)
Pair("file_id", data.data)
),
interceptor = headerInterceptor
)
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
//Log.i(TAG, "Request headers => ${req.headers}")
@ -218,6 +294,13 @@ class OpenSubtitlesApi : SubtitleAPI() {
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

View file

@ -2,36 +2,38 @@ 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.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.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.OAuth2API
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.ui.result.txt
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import com.lagradost.cloudstream3.utils.txt
import okhttp3.Interceptor
import okhttp3.Response
import java.math.BigInteger
import java.security.SecureRandom
import java.text.SimpleDateFormat
@ -43,22 +45,25 @@ import kotlin.time.Duration
import kotlin.time.DurationUnit
import kotlin.time.toDuration
class SimklApi : SyncAPI() {
class SimklApi(index: Int) : AccountManager(index), 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<String>(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
@ -96,7 +101,7 @@ class SimklApi : SyncAPI() {
fun cleanOldCache() {
getKeys(SIMKL_CACHE_KEY)?.forEach {
val isOld = CloudStreamApp.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false
val isOld = AcraApplication.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false
if (isOld) {
removeKey(it)
}
@ -117,8 +122,13 @@ class SimklApi : SyncAPI() {
* Gets cached object, if object is not fresh returns null and removes it from cache
*/
inline fun <reified T : Any> getKey(path: String): T? {
// Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject"
val type = mapper.typeFactory.constructParametricType(
SimklCacheWrapper::class.java,
T::class.java
)
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
tryParseJson<SimklCacheWrapper<T>>(it)
mapper.readValue<SimklCacheWrapper<T>>(it, type)
}
return if (cache?.isFresh() == true) {
@ -138,6 +148,10 @@ class SimklApi : 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"
@ -223,23 +237,13 @@ class SimklApi : SyncAPI() {
/** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */
data class SettingsResponse(
@JsonProperty("user")
val user: User,
@JsonProperty("account")
val account: Account,
val user: User
) {
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(
@ -361,7 +365,7 @@ class SimklApi : SyncAPI() {
class SimklScoreBuilder private constructor() {
data class Builder(
private var url: String? = null,
private var headers: Map<String, String>? = null,
private var interceptor: Interceptor? = null,
private var ids: MediaObject.Ids? = null,
private var score: Int? = null,
private var status: Int? = null,
@ -370,7 +374,7 @@ class SimklApi : SyncAPI() {
// Required for knowing if the status should be overwritten
private var onList: Boolean = false
) {
fun token(token: AuthToken) = apply { this.headers = getHeaders(token) }
fun interceptor(interceptor: Interceptor) = apply { this.interceptor = interceptor }
fun apiUrl(url: String) = apply { this.url = url }
fun ids(ids: MediaObject.Ids) = apply { this.ids = ids }
fun score(score: Int?, oldScore: Int?) = apply {
@ -419,7 +423,7 @@ class SimklApi : 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",
@ -427,7 +431,7 @@ class SimklApi : SyncAPI() {
shows = listOf(HistoryMediaObject(ids = ids)),
movies = emptyList()
),
headers = headers
interceptor = interceptor
).isSuccessful
} else {
val statusResponse = this.status?.let { setStatus ->
@ -448,7 +452,7 @@ class SimklApi : SyncAPI() {
)
), movies = emptyList()
),
headers = headers
interceptor = interceptor
).isSuccessful
} ?: true
@ -465,7 +469,7 @@ class SimklApi : SyncAPI() {
),
movies = emptyList()
),
headers = headers
interceptor = interceptor
).isSuccessful
} ?: true
@ -492,7 +496,7 @@ class SimklApi : SyncAPI() {
)
), movies = emptyList()
),
headers = headers
interceptor = interceptor
).isSuccessful
} else {
true
@ -504,9 +508,6 @@ class SimklApi : SyncAPI() {
}
}
fun getHeaders(token: AuthToken): Map<String, String> =
mapOf("Authorization" to "Bearer ${token.accessToken}", "simkl-api-key" to CLIENT_ID)
suspend fun getEpisodes(
simklId: Int?,
type: String?,
@ -663,7 +664,7 @@ class SimklApi : SyncAPI() {
movie.ids.simkl.toString(),
this.watchedEpisodesCount,
this.totalEpisodesCount,
Score.from10(this.userRating),
this.userRating?.times(10),
getUnixTime(lastWatchedAt) ?: 0,
"Simkl",
TvType.Movie,
@ -696,7 +697,7 @@ class SimklApi : SyncAPI() {
show.ids.simkl.toString(),
this.watchedEpisodesCount,
this.totalEpisodesCount,
Score.from10(this.userRating),
this.userRating?.times(10),
getUnixTime(lastWatchedAt) ?: 0,
"Simkl",
TvType.Anime,
@ -745,7 +746,7 @@ class SimklApi : 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(
@ -756,12 +757,14 @@ class SimklApi : SyncAPI() {
.build()
)
}
}*/
private suspend fun getUser(token: AuthToken): SettingsResponse =
app.post("$mainUrl/users/settings", headers = getHeaders(token))
.parsed<SettingsResponse>()
}
private suspend fun getUser(): SettingsResponse.User? {
return suspendSafeApiCall {
app.post("$mainUrl/users/settings", interceptor = interceptor)
.parsedSafe<SettingsResponse>()?.user
}
}
/**
* Useful to get episodes on demand to prevent unnecessary requests.
@ -779,7 +782,7 @@ class SimklApi : SyncAPI() {
class SimklSyncStatus(
override var status: SyncWatchType,
override var score: Score?,
override var score: Int?,
val oldScore: Int?,
override var watchedEpisodes: Int?,
val episodeConstructor: SimklEpisodeConstructor,
@ -791,8 +794,7 @@ class SimklApi : SyncAPI() {
val oldStatus: String?
) : SyncAPI.AbstractSyncStatus()
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
if (auth == null) return null
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val realIds = readIdFromString(id)
// Key which assumes all ids are the same each time :/
@ -816,7 +818,7 @@ class SimklApi : SyncAPI() {
searchResult.hasEnded()
)
val foundItem = getSyncListSmart(auth)?.let { list ->
val foundItem = getSyncListSmart()?.let { list ->
listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show ->
realIds.any { (database, id) ->
show.getIds().matchesId(database, id)
@ -834,7 +836,7 @@ class SimklApi : SyncAPI() {
)
}
?: return null,
score = Score.from10(foundItem.userRating),
score = foundItem.userRating,
watchedEpisodes = foundItem.watchedEpisodesCount,
maxEpisodes = searchResult.totalEpisodes,
episodeConstructor = episodeConstructor,
@ -845,7 +847,7 @@ class SimklApi : SyncAPI() {
} else {
return SimklSyncStatus(
status = SyncWatchType.fromInternalId(SimklListStatusType.None.value),
score = null,
score = 0,
watchedEpisodes = 0,
maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes,
episodeConstructor = episodeConstructor,
@ -856,26 +858,22 @@ class SimklApi : SyncAPI() {
}
}
override suspend fun updateStatus(
auth: AuthData?,
id: String,
newStatus: AbstractSyncStatus
): Boolean {
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
val parsedId = readIdFromString(id)
lastScoreTime = unixTime
val simklStatus = newStatus as? SimklSyncStatus
val simklStatus = status as? SimklSyncStatus
val builder = SimklScoreBuilder.Builder()
.apiUrl(this.mainUrl)
.score(newStatus.score?.toInt(10), simklStatus?.oldScore)
.score(status.score, simklStatus?.oldScore)
.status(
newStatus.status.internalId,
(newStatus as? SimklSyncStatus)?.oldStatus?.let { oldStatus ->
status.status.internalId,
(status as? SimklSyncStatus)?.oldStatus?.let { oldStatus ->
SimklListStatusType.entries.firstOrNull {
it.originalName == oldStatus
}?.value
})
.token(auth?.token ?: return false)
.interceptor(interceptor)
.ids(MediaObject.Ids.fromMap(parsedId))
@ -883,12 +881,11 @@ class SimklApi : SyncAPI() {
val episodes = simklStatus?.episodeConstructor?.getEpisodes()
// All episodes if marked as completed
val watchedEpisodes =
if (newStatus.status.internalId == SimklListStatusType.Completed.value) {
episodes?.size
} else {
newStatus.watchedEpisodes
}
val watchedEpisodes = if (status.status.internalId == SimklListStatusType.Completed.value) {
episodes?.size
} else {
status.watchedEpisodes
}
builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes)
@ -909,26 +906,39 @@ class SimklApi : SyncAPI() {
).parsedSafe()
}
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
return app.get(
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query)
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name)
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
}
override fun loginRequest(): AuthLoginPage? {
val lastLoginState = BigInteger(130, SecureRandom()).toString(32)
override fun authenticate(activity: FragmentActivity?) {
lastLoginState = BigInteger(130, SecureRandom()).toString(32)
val url =
"https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}&state=$lastLoginState"
return AuthLoginPage(
url = url,
payload = lastLoginState
)
"https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState"
openBrowser(url, activity)
}
override suspend fun load(auth: AuthData?, id: String): SyncResult? = null
override fun loginInfo(): AuthAPI.LoginInfo? {
return getKey<SettingsResponse.User>(accountId, SIMKL_USER_KEY)?.let { user ->
AuthAPI.LoginInfo(
name = user.name,
profilePicture = user.avatar,
accountIndex = accountIndex
)
}
}
private suspend fun getSyncListSince(auth: AuthData, since: Long?): AllItemsResponse? {
override fun logOut() {
requireLibraryRefresh = true
removeAccountKeys()
}
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
return null
}
private suspend fun getSyncListSince(since: Long?): AllItemsResponse? {
val params = getDateTime(since)?.let {
mapOf("date_from" to it)
} ?: emptyMap()
@ -937,22 +947,23 @@ class SimklApi : SyncAPI() {
return app.get(
"$mainUrl/sync/all-items/",
params = params,
headers = getHeaders(auth.token)
interceptor = interceptor
).parsedSafe()
}
private suspend fun getActivities(token: AuthToken): ActivitiesResponse? {
return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe()
private suspend fun getActivities(): ActivitiesResponse? {
return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe()
}
private fun getSyncListCached(auth: AuthData): AllItemsResponse? {
return getKey<AllItemsResponse>(SIMKL_CACHED_LIST, auth.user.id.toString())
private fun getSyncListCached(): AllItemsResponse? {
return getKey(accountId, SIMKL_CACHED_LIST)
}
private suspend fun getSyncListSmart(auth: AuthData): AllItemsResponse? {
val activities = getActivities(auth.token)
val userId = auth.user.id.toString()
val lastCacheUpdate = getKey<Long>(SIMKL_CACHED_LIST_TIME, auth.user.id.toString())
private suspend fun getSyncListSmart(): AllItemsResponse? {
if (token == null) return null
val activities = getActivities()
val lastCacheUpdate = getKey<Long>(accountId, SIMKL_CACHED_LIST_TIME)
val lastRemoval = listOf(
activities?.tvShows?.removedFromList,
activities?.anime?.removedFromList,
@ -972,28 +983,26 @@ class SimklApi : 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(SIMKL_CACHED_LIST_TIME, userId, lastRemoval)
getSyncListSince(auth, null)
setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval)
getSyncListSince(null)
} else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) {
debugPrint { "Partial list update in ${this.name}." }
setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate)
AllItemsResponse.merge(
getSyncListCached(auth),
getSyncListSince(auth, lastCacheUpdate)
)
setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate)
AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate))
} else {
debugPrint { "Cached list update in ${this.name}." }
getSyncListCached(auth)
getSyncListCached()
}
debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" }
setKey(SIMKL_CACHED_LIST, userId, list)
setKey(accountId, SIMKL_CACHED_LIST, list)
return list
}
override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
val list = getSyncListSmart(auth ?: return null) ?: return null
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
val list = getSyncListSmart() ?: return null
val baseMap =
SimklListStatusType.entries
@ -1029,17 +1038,17 @@ class SimklApi : SyncAPI() {
)
}
override fun urlToId(url: String): String? {
override fun getIdFromUrl(url: String): String {
val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""")
return simklUrlRegex.find(url)?.groupValues?.get(1) ?: ""
}
override suspend fun pinRequest(): AuthPinData? {
override suspend fun getDevicePin(): OAuth2API.PinAuthData? {
val pinAuthResp = app.get(
"$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}"
"$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}"
).parsedSafe<PinAuthResponse>() ?: return null
return AuthPinData(
return OAuth2API.PinAuthData(
deviceCode = pinAuthResp.deviceCode,
userCode = pinAuthResp.userCode,
verificationUrl = pinAuthResp.verificationUrl,
@ -1048,38 +1057,56 @@ class SimklApi : SyncAPI() {
)
}
override suspend fun login(payload: AuthPinData): AuthToken? {
override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean {
val pinAuthResp = app.get(
"$mainUrl/oauth/pin/${payload.userCode}?client_id=$CLIENT_ID"
).parsedSafe<PinExchangeResponse>() ?: return null
"$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID"
).parsedSafe<PinExchangeResponse>() ?: return false
return AuthToken(
accessToken = pinAuthResp.accessToken ?: return null,
)
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
}
setKey(accountId, SIMKL_USER_KEY, user)
registerAccount()
requireLibraryRefresh = true
return true
}
return false
}
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
val uri = redirectUrl.toUri()
override suspend fun handleRedirect(url: String): Boolean {
val uri = url.toUri()
val state = uri.getQueryParameter("state")
// Ensure consistent state
if (state != payload) return null
if (state != lastLoginState) return false
lastLoginState = ""
val code = uri.getQueryParameter("code") ?: return null
val tokenResponse = app.post(
val code = uri.getQueryParameter("code") ?: return false
val token = app.post(
"$mainUrl/oauth/token", json = TokenRequest(code)
).parsedSafe<TokenResponse>() ?: return null
).parsedSafe<TokenResponse>() ?: return false
return AuthToken(
accessToken = tokenResponse.accessToken,
)
}
switchToNewAccount()
setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken)
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
)
val user = getUser()
if (user == null) {
removeKey(accountId, SIMKL_TOKEN_KEY)
switchToOldAccount()
return false
}
setKey(accountId, SIMKL_USER_KEY, user)
registerAccount()
requireLibraryRefresh = true
return true
}
}

View file

@ -3,33 +3,27 @@ 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 : SubtitleAPI() {
override val name = "SubSource"
class SubSourceApi : AbstractSubProvider {
override val idPrefix = "subsource"
override val requiresLogin = false
val name = "SubSource"
companion object {
const val APIURL = "https://api.subsource.net/api"
const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
}
override suspend fun search(
auth: AuthData?,
query: AbstractSubtitleEntities.SubtitleSearch
): List<AbstractSubtitleEntities.SubtitleEntity>? {
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
//Only supports Imdb Id search for now
if (query.imdbId == null) return null
val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang)
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
val searchRes = app.post(
@ -93,17 +87,15 @@ class SubSourceApi : SubtitleAPI() {
}
}
override suspend fun SubtitleResource.getResources(
auth: AuthData?,
subtitle: AbstractSubtitleEntities.SubtitleEntity
) {
val parsedSub = parseJson<SubData>(subtitle.data)
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
val parsedSub = parseJson<SubData>(data.data)
val subRes = app.post(
url = "$APIURL/getSub",
data = mapOf(
"movie" to parsedSub.movie,
"lang" to subtitle.lang,
"lang" to data.lang,
"id" to parsedSub.id
)
).parsedSafe<SubTitleLink>() ?: return

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,9 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
import androidx.appcompat.app.AlertDialog
import com.google.android.gms.cast.MediaLoadOptions
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.MediaSeekOptions
import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF
@ -102,6 +104,9 @@ data class MetadataHolder(
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
UIController() {
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
init {
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
view.setOnClickListener {
@ -234,27 +239,12 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
loadMirror(index + 1)
}
} else {
val mediaLoadOptions =
MediaLoadOptions.Builder()
.setPlayPosition(startAt)
.setAutoplay(true)
.build()
awaitLinks(
remoteMediaClient?.load(
mediaItem,
mediaLoadOptions
)
) {
awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) {
loadMirror(index + 1)
}
}
} catch (e: Exception) {
val mediaLoadOptions =
MediaLoadOptions.Builder()
.setPlayPosition(startAt)
.setAutoplay(true)
.build()
awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) {
awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) {
loadMirror(index + 1)
}
}
@ -298,13 +288,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val currentDuration = remoteMediaClient?.streamDuration
val currentPosition = remoteMediaClient?.approximateStreamPosition
if (currentDuration != null && currentPosition != null)
DataStoreHelper.setViewPosAndResume(
epData.id,
currentPosition,
currentDuration,
epData,
meta.episodes.getOrNull(index + 1)
)
DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration)
} catch (t: Throwable) {
logError(t)
}
@ -320,7 +304,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val isSuccessful = safeApiCall {
generator.generateLinks(
clearCache = false,
sourceTypes = LOADTYPE_CHROMECAST,
allowedTypes = LOADTYPE_CHROMECAST,
callback = {
it.first?.let { link ->
currentLinks.add(link)
@ -328,9 +312,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
}, subtitleCallback = {
currentSubs.add(it)
},
offset = 0,
isCasting = true
)
isCasting = true)
}
val sortedLinks = sortUrls(currentLinks)
@ -443,4 +425,4 @@ class ControllerActivity : ExpandedControllerActivity() {
SkipNextEpisodeController(skipOpButton)
)
}
}
}

View file

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

View file

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

View file

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

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