mirror of
https://github.com/recloudstream/cloudstream.git
synced 2026-06-20 20:15:40 +00:00
Compare commits
5 commits
master
...
action-api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1be78df977 |
||
|
|
e288c83c3d | ||
|
|
6f76352cbe | ||
|
|
9505ca2592 | ||
|
|
1d55610685 |
715 changed files with 26902 additions and 58009 deletions
30
.github/locales.py
vendored
30
.github/locales.py
vendored
|
|
@ -1,13 +1,14 @@
|
||||||
import re
|
import re
|
||||||
import glob
|
import glob
|
||||||
import requests
|
import requests
|
||||||
|
import os
|
||||||
import lxml.etree as ET # builtin library doesn't preserve comments
|
import lxml.etree as ET # builtin library doesn't preserve comments
|
||||||
|
|
||||||
|
|
||||||
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
|
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
|
||||||
START_MARKER = "/* begin language list */"
|
START_MARKER = "/* begin language list */"
|
||||||
END_MARKER = "/* end language list */"
|
END_MARKER = "/* end language list */"
|
||||||
XML_NAME = "app/src/main/res/values-b+"
|
XML_NAME = "app/src/main/res/values-"
|
||||||
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
|
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
|
||||||
INDENT = " "*4
|
INDENT = " "*4
|
||||||
|
|
||||||
|
|
@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER)
|
||||||
|
|
||||||
# Load already added langs
|
# Load already added langs
|
||||||
languages = {}
|
languages = {}
|
||||||
for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest):
|
for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
|
||||||
name, iso = lang.groups()
|
flag, name, iso = lang.groups()
|
||||||
languages[iso] = name
|
languages[iso] = (flag, name)
|
||||||
|
|
||||||
# Add not yet added langs
|
# Add not yet added langs
|
||||||
for folder in glob.glob(f"{XML_NAME}*"):
|
for folder in glob.glob(f"{XML_NAME}*"):
|
||||||
iso = folder[len(XML_NAME):].replace("+", "-")
|
iso = folder[len(XML_NAME):]
|
||||||
if iso not in languages.keys():
|
if iso not in languages.keys():
|
||||||
entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found
|
entry = iso_map.get(iso.lower(),{'nativeName':iso})
|
||||||
languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple
|
languages[iso] = ("", entry['nativeName'].split(',')[0])
|
||||||
|
|
||||||
# Create pairs
|
# Create triples
|
||||||
pairs = []
|
triples = []
|
||||||
for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name
|
for iso in sorted(languages.keys()):
|
||||||
name = languages[iso]
|
flag, name = languages[iso]
|
||||||
pairs.append(f'{INDENT}Pair("{name}", "{iso}"),')
|
triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
|
||||||
|
|
||||||
# Update settings file
|
# Update settings file
|
||||||
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
|
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
|
||||||
before_src +
|
before_src +
|
||||||
START_MARKER +
|
START_MARKER +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\n".join(pairs) +
|
"\n".join(triples) +
|
||||||
"\n" +
|
"\n" +
|
||||||
END_MARKER +
|
END_MARKER +
|
||||||
after_src
|
after_src
|
||||||
|
|
@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
|
||||||
with open(file, 'wb') as fp:
|
with open(file, 'wb') as fp:
|
||||||
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
|
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
|
||||||
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
|
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
|
||||||
|
# Remove trailing new line to be consistent with weblate
|
||||||
|
fp.seek(-1, os.SEEK_END)
|
||||||
|
fp.truncate()
|
||||||
except ET.ParseError as ex:
|
except ET.ParseError as ex:
|
||||||
print(f"[{file}] {ex}")
|
print(f"[{file}] {ex}")
|
||||||
|
|
|
||||||
35
.github/workflows/build_to_archive.yml
vendored
35
.github/workflows/build_to_archive.yml
vendored
|
|
@ -9,9 +9,6 @@ on:
|
||||||
- '**/wcokey.txt'
|
- '**/wcokey.txt'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "Archive-build"
|
group: "Archive-build"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
@ -27,7 +24,6 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/secrets"
|
repository: "recloudstream/secrets"
|
||||||
|
|
||||||
- name: Generate access token (archive)
|
- name: Generate access token (archive)
|
||||||
id: generate_archive_token
|
id: generate_archive_token
|
||||||
uses: tibdex/github-app-token@v2
|
uses: tibdex/github-app-token@v2
|
||||||
|
|
@ -35,18 +31,14 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/cloudstream-archive"
|
repository: "recloudstream/cloudstream-archive"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
java-version: '17'
|
||||||
java-version: 17
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Fetch keystore
|
- name: Fetch keystore
|
||||||
id: fetch_keystore
|
id: fetch_keystore
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -57,33 +49,24 @@ jobs:
|
||||||
KEY_PWD="$(cat keystore_password.txt)"
|
KEY_PWD="$(cat keystore_password.txt)"
|
||||||
echo "::add-mask::${KEY_PWD}"
|
echo "::add-mask::${KEY_PWD}"
|
||||||
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v5
|
|
||||||
with:
|
|
||||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
|
||||||
|
|
||||||
- name: Run Gradle
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrereleaseRelease
|
run: |
|
||||||
|
./gradlew assemblePrerelease
|
||||||
env:
|
env:
|
||||||
SIGNING_KEY_ALIAS: "key0"
|
SIGNING_KEY_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
- uses: actions/checkout@v4
|
||||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
|
||||||
MAL_KEY: ${{ secrets.MAL_KEY }}
|
|
||||||
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
|
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
with:
|
||||||
repository: "recloudstream/cloudstream-archive"
|
repository: "recloudstream/cloudstream-archive"
|
||||||
token: ${{ steps.generate_archive_token.outputs.token }}
|
token: ${{ steps.generate_archive_token.outputs.token }}
|
||||||
path: "archive"
|
path: "archive"
|
||||||
|
|
||||||
- name: Move build
|
- name: Move build
|
||||||
run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
|
run: |
|
||||||
|
cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
|
||||||
|
|
||||||
- name: Push archive
|
- name: Push archive
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
44
.github/workflows/generate_dokka.yml
vendored
44
.github/workflows/generate_dokka.yml
vendored
|
|
@ -1,18 +1,19 @@
|
||||||
name: Dokka
|
name: Dokka
|
||||||
|
|
||||||
on:
|
# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
paths-ignore:
|
|
||||||
- '*.md'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "dokka"
|
group: "dokka"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
# choose your default branch
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -24,14 +25,13 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/dokka"
|
repository: "recloudstream/dokka"
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@master
|
||||||
with:
|
with:
|
||||||
path: "src"
|
path: "src"
|
||||||
|
|
||||||
- name: Checkout dokka
|
- name: Checkout dokka
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@master
|
||||||
with:
|
with:
|
||||||
repository: "recloudstream/dokka"
|
repository: "recloudstream/dokka"
|
||||||
path: "dokka"
|
path: "dokka"
|
||||||
|
|
@ -40,28 +40,26 @@ jobs:
|
||||||
- name: Clean old builds
|
- name: Clean old builds
|
||||||
run: |
|
run: |
|
||||||
cd $GITHUB_WORKSPACE/dokka/
|
cd $GITHUB_WORKSPACE/dokka/
|
||||||
rm -rf "./app"
|
rm -rf "./-cloudstream"
|
||||||
rm -rf "./library"
|
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Setup JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
|
||||||
java-version: 17
|
java-version: 17
|
||||||
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Android SDK
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
|
||||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
|
||||||
|
|
||||||
- name: Generate Dokka
|
- name: Generate Dokka
|
||||||
run: |
|
run: |
|
||||||
cd $GITHUB_WORKSPACE/src/
|
cd $GITHUB_WORKSPACE/src/
|
||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
./gradlew docs:dokkaGeneratePublicationHtml
|
./gradlew docs:dokkaHtml
|
||||||
|
|
||||||
- name: Copy Dokka
|
- name: Copy Dokka
|
||||||
run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
|
run: |
|
||||||
|
cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
|
||||||
|
|
||||||
- name: Push builds
|
- name: Push builds
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
88
.github/workflows/issue_action.yml
vendored
Normal file
88
.github/workflows/issue_action.yml
vendored
Normal 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'
|
||||||
|
|
||||||
|
|
||||||
32
.github/workflows/prerelease.yml
vendored
32
.github/workflows/prerelease.yml
vendored
|
|
@ -12,9 +12,6 @@ concurrency:
|
||||||
group: "pre-release"
|
group: "pre-release"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -26,18 +23,14 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/secrets"
|
repository: "recloudstream/secrets"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
java-version: '17'
|
||||||
java-version: 17
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Fetch keystore
|
- name: Fetch keystore
|
||||||
id: fetch_keystore
|
id: fetch_keystore
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -48,27 +41,18 @@ jobs:
|
||||||
KEY_PWD="$(cat keystore_password.txt)"
|
KEY_PWD="$(cat keystore_password.txt)"
|
||||||
echo "::add-mask::${KEY_PWD}"
|
echo "::add-mask::${KEY_PWD}"
|
||||||
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v5
|
|
||||||
with:
|
|
||||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
|
||||||
|
|
||||||
- name: Run Gradle
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
|
run: |
|
||||||
|
./gradlew assemblePrerelease build androidSourcesJar
|
||||||
|
./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
|
||||||
env:
|
env:
|
||||||
SIGNING_KEY_ALIAS: "key0"
|
SIGNING_KEY_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
|
||||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
|
||||||
MAL_KEY: ${{ secrets.MAL_KEY }}
|
|
||||||
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
|
|
||||||
|
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
uses: marvinpinto/action-automatic-releases@latest
|
uses: "marvinpinto/action-automatic-releases@latest"
|
||||||
with:
|
with:
|
||||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
automatic_release_tag: "pre-release"
|
automatic_release_tag: "pre-release"
|
||||||
|
|
|
||||||
25
.github/workflows/pull_request.yml
vendored
25
.github/workflows/pull_request.yml
vendored
|
|
@ -2,35 +2,22 @@ name: Artifact Build
|
||||||
|
|
||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
java-version: '17'
|
||||||
java-version: 17
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v5
|
|
||||||
with:
|
|
||||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
|
||||||
cache-read-only: false
|
|
||||||
|
|
||||||
- name: Run Gradle
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrereleaseDebug lint check
|
run: ./gradlew assemblePrereleaseDebug
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: pull-request-build
|
name: pull-request-build
|
||||||
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
||||||
|
|
|
||||||
20
.github/workflows/update_locales.yml
vendored
20
.github/workflows/update_locales.yml
vendored
|
|
@ -1,19 +1,17 @@
|
||||||
name: Fix locale issues
|
name: Fix locale issues
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
|
||||||
paths:
|
paths:
|
||||||
- '**.xml'
|
- '**.xml'
|
||||||
workflow_dispatch:
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "locale"
|
group: "locale"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create:
|
create:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -25,17 +23,15 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/cloudstream"
|
repository: "recloudstream/cloudstream"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip3 install lxml requests
|
run: |
|
||||||
|
pip3 install lxml
|
||||||
- name: Edit files
|
- name: Edit files
|
||||||
run: python3 .github/locales.py
|
run: |
|
||||||
|
python3 .github/locales.py
|
||||||
- name: Commit to the repo
|
- name: Commit to the repo
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
|
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
|
||||||
|
|
|
||||||
220
.gitignore
vendored
220
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea/caches
|
/.idea/caches
|
||||||
/.idea/misc.xml
|
/.idea/misc.xml
|
||||||
|
|
@ -9,220 +11,6 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
.cxx
|
|
||||||
.kotlin/*
|
|
||||||
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode
|
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode
|
|
||||||
|
|
||||||
### Android ###
|
|
||||||
# Gradle files
|
|
||||||
.gradle/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
|
||||||
local.properties
|
|
||||||
|
|
||||||
# Log/OS Files
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Android Studio generated files and folders
|
|
||||||
captures/
|
|
||||||
.externalNativeBuild/
|
|
||||||
.cxx/
|
|
||||||
*.apk
|
|
||||||
output.json
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
*.iml
|
|
||||||
.idea/
|
|
||||||
misc.xml
|
|
||||||
deploymentTargetDropDown.xml
|
|
||||||
render.experimental.xml
|
|
||||||
|
|
||||||
# Keystore files
|
|
||||||
*.jks
|
|
||||||
*.keystore
|
|
||||||
|
|
||||||
# Google Services (e.g. APIs or Firebase)
|
|
||||||
google-services.json
|
|
||||||
|
|
||||||
# Android Profiling
|
|
||||||
*.hprof
|
|
||||||
|
|
||||||
### Android Patch ###
|
|
||||||
gen-external-apklibs
|
|
||||||
|
|
||||||
# Replacement of .externalNativeBuild directories introduced
|
|
||||||
# with Android Studio 3.5.
|
|
||||||
|
|
||||||
### Java ###
|
|
||||||
# Compiled class file
|
|
||||||
*.class
|
|
||||||
|
|
||||||
# Log file
|
|
||||||
|
|
||||||
# BlueJ files
|
|
||||||
*.ctxt
|
|
||||||
|
|
||||||
# Mobile Tools for Java (J2ME)
|
|
||||||
.mtj.tmp/
|
|
||||||
|
|
||||||
# Package Files #
|
|
||||||
*.jar
|
|
||||||
*.war
|
|
||||||
*.nar
|
|
||||||
*.ear
|
|
||||||
*.zip
|
|
||||||
*.tar.gz
|
|
||||||
*.rar
|
|
||||||
|
|
||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
|
||||||
hs_err_pid*
|
|
||||||
replay_pid*
|
|
||||||
|
|
||||||
### Kotlin ###
|
|
||||||
# Compiled class file
|
|
||||||
|
|
||||||
# Log file
|
|
||||||
|
|
||||||
# BlueJ files
|
|
||||||
|
|
||||||
# Mobile Tools for Java (J2ME)
|
|
||||||
|
|
||||||
# Package Files #
|
|
||||||
|
|
||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
|
||||||
|
|
||||||
### VisualStudioCode ###
|
|
||||||
.vscode/*
|
|
||||||
|
|
||||||
# Local History for Visual Studio Code
|
|
||||||
.history/
|
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
|
||||||
*.vsix
|
|
||||||
|
|
||||||
### VisualStudioCode Patch ###
|
|
||||||
# Ignore all local history of files
|
|
||||||
.history
|
|
||||||
.ionide
|
|
||||||
|
|
||||||
### AndroidStudio ###
|
|
||||||
# Covers files to be ignored for android development using Android Studio.
|
|
||||||
|
|
||||||
# Built application files
|
|
||||||
*.ap_
|
|
||||||
*.aab
|
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
|
||||||
*.dex
|
|
||||||
|
|
||||||
# Java class files
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
bin/
|
|
||||||
gen/
|
|
||||||
out/
|
|
||||||
|
|
||||||
# Gradle files
|
|
||||||
.gradle
|
|
||||||
|
|
||||||
# Signing files
|
|
||||||
.signing/
|
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
|
||||||
|
|
||||||
# Proguard folder generated by Eclipse
|
|
||||||
proguard/
|
|
||||||
|
|
||||||
# Log Files
|
|
||||||
|
|
||||||
# Android Studio
|
|
||||||
/*/build/
|
|
||||||
/*/local.properties
|
|
||||||
/*/out
|
|
||||||
/*/*/build
|
|
||||||
/*/*/production
|
|
||||||
.navigation/
|
|
||||||
*.ipr
|
|
||||||
*~
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
# Keystore files
|
|
||||||
|
|
||||||
# Google Services (e.g. APIs or Firebase)
|
|
||||||
# google-services.json
|
|
||||||
|
|
||||||
# Android Patch
|
|
||||||
|
|
||||||
# External native build folder generated in Android Studio 2.2 and later
|
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
# NDK
|
local.properties
|
||||||
obj/
|
|
||||||
|
|
||||||
# IntelliJ IDEA
|
|
||||||
*.iws
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# User-specific configurations
|
|
||||||
.idea/caches/
|
|
||||||
.idea/libraries/
|
|
||||||
.idea/shelf/
|
|
||||||
.idea/workspace.xml
|
|
||||||
.idea/tasks.xml
|
|
||||||
.idea/.name
|
|
||||||
.idea/compiler.xml
|
|
||||||
.idea/copyright/profiles_settings.xml
|
|
||||||
.idea/encodings.xml
|
|
||||||
.idea/misc.xml
|
|
||||||
.idea/modules.xml
|
|
||||||
.idea/scopes/scope_settings.xml
|
|
||||||
.idea/dictionaries
|
|
||||||
.idea/vcs.xml
|
|
||||||
.idea/jsLibraryMappings.xml
|
|
||||||
.idea/datasources.xml
|
|
||||||
.idea/dataSources.ids
|
|
||||||
.idea/sqlDataSources.xml
|
|
||||||
.idea/dynamic.xml
|
|
||||||
.idea/uiDesigner.xml
|
|
||||||
.idea/assetWizardSettings.xml
|
|
||||||
.idea/gradle.xml
|
|
||||||
.idea/jarRepositories.xml
|
|
||||||
.idea/navEditor.xml
|
|
||||||
|
|
||||||
# Legacy Eclipse project files
|
|
||||||
.classpath
|
|
||||||
.project
|
|
||||||
.cproject
|
|
||||||
.settings/
|
|
||||||
|
|
||||||
# Mobile Tools for Java (J2ME)
|
|
||||||
|
|
||||||
# Package Files #
|
|
||||||
|
|
||||||
# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
|
|
||||||
|
|
||||||
## Plugin-specific files:
|
|
||||||
|
|
||||||
# mpeltonen/sbt-idea plugin
|
|
||||||
.idea_modules/
|
|
||||||
|
|
||||||
# JIRA plugin
|
|
||||||
atlassian-ide-plugin.xml
|
|
||||||
|
|
||||||
# Mongo Explorer plugin
|
|
||||||
.idea/mongoSettings.xml
|
|
||||||
|
|
||||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
|
||||||
com_crashlytics_export_strings.xml
|
|
||||||
crashlytics.properties
|
|
||||||
crashlytics-build.properties
|
|
||||||
fabric.properties
|
|
||||||
|
|
||||||
### AndroidStudio Patch ###
|
|
||||||
|
|
||||||
!/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode
|
|
||||||
|
|
|
||||||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CloudStream
|
||||||
123
.idea/codeStyles/Project.xml
generated
Normal file
123
.idea/codeStyles/Project.xml
generated
Normal 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
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal 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
6
.idea/compiler.xml
generated
Normal 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
7
.idea/discord.xml
generated
Normal 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
21
.idea/gradle.xml
generated
Normal 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
40
.idea/jarRepositories.xml
generated
Normal 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
6
.idea/studiobot.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
6
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"githubPullRequests.ignoredPullRequestBranches": [
|
||||||
|
"master"
|
||||||
|
],
|
||||||
|
"java.configuration.updateBuildConfiguration": "interactive"
|
||||||
|
}
|
||||||
11
AI-POLICY.md
11
AI-POLICY.md
|
|
@ -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.
|
|
||||||
98
README.md
98
README.md
|
|
@ -1,46 +1,11 @@
|
||||||
# CloudStream
|
# 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.**
|
||||||
|
|
||||||
|
|
||||||
[](https://discord.gg/5Hus6fM)
|
[](https://discord.gg/5Hus6fM)
|
||||||
|
|
||||||
|
### Features:
|
||||||
## 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:
|
|
||||||
+ **AdFree**, No ads whatsoever
|
+ **AdFree**, No ads whatsoever
|
||||||
+ No tracking/analytics
|
+ No tracking/analytics
|
||||||
+ Bookmarks
|
+ Bookmarks
|
||||||
|
|
@ -48,64 +13,7 @@ Our documentation is unmaintained and open to contributions; therefore, apps and
|
||||||
+ Chromecast
|
+ Chromecast
|
||||||
+ Extension system for personal customization
|
+ 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:
|
### 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/">
|
<a href="https://hosted.weblate.org/engage/cloudstream/">
|
||||||
<img src="https://hosted.weblate.org/widgets/cloudstream/-/app/multi-auto.svg" alt="Translation status" />
|
<img src="https://hosted.weblate.org/widgets/cloudstream/-/app/multi-auto.svg" alt="Translation status" />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
6
app/CMakeLists.txt
Normal file
6
app/CMakeLists.txt
Normal 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})
|
||||||
|
|
@ -1,96 +1,48 @@
|
||||||
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||||
import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
|
import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName
|
||||||
import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
|
import java.io.ByteArrayOutputStream
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
id("com.android.application")
|
||||||
alias(libs.plugins.dokka)
|
id("com.google.devtools.ksp")
|
||||||
alias(libs.plugins.kotlin.serialization)
|
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() {
|
fun String.execute() = ByteArrayOutputStream().use { baot ->
|
||||||
|
if (project.exec {
|
||||||
@get:InputFile
|
workingDir = projectDir
|
||||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
commandLine = this@execute.split(Regex("\\s"))
|
||||||
abstract val headFile: RegularFileProperty
|
standardOutput = baot
|
||||||
|
}.exitValue == 0)
|
||||||
@get:InputDirectory
|
String(baot.toByteArray()).trim()
|
||||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
else null
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@Suppress("UnstableApiUsage")
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
|
viewBinding {
|
||||||
dependenciesInfo {
|
enable = true
|
||||||
// Disables dependency metadata when building APKs.
|
|
||||||
includeInApk = false
|
|
||||||
// Disables dependency metadata when building Android App Bundles.
|
|
||||||
includeInBundle = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
androidComponents {
|
/* disable this for now
|
||||||
onVariants { variant ->
|
externalNativeBuild {
|
||||||
variant.sources.assets?.addGeneratedSourceDirectory(
|
cmake {
|
||||||
generateGitHash,
|
path("CMakeLists.txt")
|
||||||
GenerateGitHashTask::outputDir
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}*/
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
// We just use SIGNING_KEY_ALIAS here since it won't change
|
if (prereleaseStoreFile != null) {
|
||||||
// so won't kill the configuration cache.
|
|
||||||
if (System.getenv("SIGNING_KEY_ALIAS") != null) {
|
|
||||||
create("prerelease") {
|
create("prerelease") {
|
||||||
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
storeFile = file(prereleaseStoreFile)
|
||||||
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
|
||||||
|
|
||||||
storeFile = prereleaseStoreFile?.let { file(it) }
|
|
||||||
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||||
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||||
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
||||||
|
|
@ -98,19 +50,23 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
compileSdk = 34
|
||||||
|
buildToolsVersion = "34.0.0"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.lagradost.cloudstream3"
|
applicationId = "com.lagradost.cloudstream3"
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = 21
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = 33 /* Android 14 is Fu*ked
|
||||||
versionCode = libs.versions.versionCode.get().toInt()
|
^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
|
||||||
versionName = libs.versions.versionName.get()
|
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
|
// Reads local.properties
|
||||||
val localProperties = gradleLocalProperties(rootDir, project.providers)
|
val localProperties = gradleLocalProperties(rootDir)
|
||||||
|
|
||||||
buildConfigField(
|
buildConfigField(
|
||||||
"long",
|
"long",
|
||||||
|
|
@ -127,17 +83,12 @@ android {
|
||||||
"SIMKL_CLIENT_SECRET",
|
"SIMKL_CLIENT_SECRET",
|
||||||
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
|
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
|
||||||
)
|
)
|
||||||
buildConfigField(
|
|
||||||
"String",
|
|
||||||
"MAL_KEY",
|
|
||||||
"\"" + (System.getenv("MAL_KEY") ?: localProperties["mal.key"]) + "\""
|
|
||||||
)
|
|
||||||
buildConfigField(
|
|
||||||
"String",
|
|
||||||
"ANILIST_KEY",
|
|
||||||
"\"" + (System.getenv("ANILIST_KEY") ?: localProperties["anilist.key"]) + "\""
|
|
||||||
)
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
ksp {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
arg("exportSchema", "true")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
@ -164,9 +115,12 @@ android {
|
||||||
productFlavors {
|
productFlavors {
|
||||||
create("stable") {
|
create("stable") {
|
||||||
dimension = "state"
|
dimension = "state"
|
||||||
|
resValue("bool", "is_prerelease", "false")
|
||||||
}
|
}
|
||||||
create("prerelease") {
|
create("prerelease") {
|
||||||
dimension = "state"
|
dimension = "state"
|
||||||
|
resValue("bool", "is_prerelease", "true")
|
||||||
|
buildConfigField("boolean", "BETA", "true")
|
||||||
applicationIdSuffix = ".prerelease"
|
applicationIdSuffix = ".prerelease"
|
||||||
if (signingConfigs.names.contains("prerelease")) {
|
if (signingConfigs.names.contains("prerelease")) {
|
||||||
signingConfig = signingConfigs.getByName("prerelease")
|
signingConfig = signingConfigs.getByName("prerelease")
|
||||||
|
|
@ -180,33 +134,17 @@ android {
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.toVersion(javaTarget.target)
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.toVersion(javaTarget.target)
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
|
||||||
|
|
||||||
java {
|
|
||||||
// Use Java 17 toolchain even if a higher JDK runs the build.
|
|
||||||
// We still use Java 8 for now which higher JDKs have deprecated.
|
|
||||||
toolchain {
|
|
||||||
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
|
abortOnError = false
|
||||||
checkReleaseBuilds = false
|
checkReleaseBuilds = false
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
viewBinding = true
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
|
||||||
jniLibs {
|
|
||||||
// Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23).
|
|
||||||
// Note: This may increase app startup time slightly.
|
|
||||||
useLegacyPackaging = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace = "com.lagradost.cloudstream3"
|
namespace = "com.lagradost.cloudstream3"
|
||||||
|
|
@ -214,89 +152,105 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation(libs.json)
|
testImplementation("org.json:json:20240303")
|
||||||
androidTestImplementation(libs.core)
|
androidTestImplementation("androidx.test:core")
|
||||||
androidTestImplementation(libs.espresso.core)
|
implementation("androidx.test.ext:junit-ktx:1.2.1")
|
||||||
androidTestImplementation(libs.ext.junit)
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
androidTestImplementation(libs.instancio.core)
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
androidTestImplementation(libs.junit.ktx)
|
|
||||||
androidTestImplementation(libs.kotlin.test)
|
|
||||||
|
|
||||||
// Android Core & Lifecycle
|
// Android Core & Lifecycle
|
||||||
implementation(libs.core.ktx)
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
implementation(libs.activity.ktx)
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
implementation(libs.annotation)
|
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
|
||||||
implementation(libs.appcompat)
|
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
|
||||||
implementation(libs.fragment.ktx)
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
|
||||||
implementation(libs.bundles.lifecycle)
|
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
|
||||||
implementation(libs.bundles.navigation)
|
|
||||||
implementation(libs.kotlinx.collections.immutable)
|
|
||||||
implementation(libs.kotlinx.serialization.json) // JSON Parser
|
|
||||||
|
|
||||||
// Design & UI
|
// Design & UI
|
||||||
implementation(libs.preference.ktx)
|
implementation("jp.wasabeef:glide-transformations:4.3.0")
|
||||||
implementation(libs.material)
|
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||||
implementation(libs.constraintlayout)
|
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
|
// Glide Module
|
||||||
implementation(libs.bundles.coil)
|
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)
|
// Media 3 (ExoPlayer)
|
||||||
implementation(libs.bundles.media3)
|
implementation("androidx.media3:media3-ui:1.4.1")
|
||||||
implementation(libs.video)
|
implementation("androidx.media3:media3-cast:1.4.1")
|
||||||
|
implementation("androidx.media3:media3-common:1.4.1")
|
||||||
// FFmpeg Decoding
|
implementation("androidx.media3:media3-session:1.4.1")
|
||||||
implementation(libs.bundles.nextlib)
|
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||||
|
implementation("com.google.android.mediahome:video:1.0.0")
|
||||||
// Anime-db for filler
|
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
|
||||||
implementation(libs.anime.db)
|
implementation("androidx.media3:media3-exoplayer-dash:1.4.1")
|
||||||
|
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
|
||||||
|
|
||||||
// PlayBack
|
// PlayBack
|
||||||
implementation(libs.colorpicker) // Subtitle Color Picker
|
implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
|
||||||
implementation(libs.newpipeextractor) // For Trailers
|
implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs
|
||||||
implementation(libs.juniversalchardet) // Subtitle Decoding
|
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
|
// UI Stuff
|
||||||
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
|
implementation("com.facebook.shimmer:shimmer:0.5.0") // Shimmering Effect (Loading Skeleton)
|
||||||
implementation(libs.palette.ktx) // Palette for Images -> Colors
|
implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors
|
||||||
implementation(libs.tvprovider)
|
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
||||||
implementation(libs.overlappingpanels) // Gestures
|
implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures
|
||||||
implementation(libs.biometric) // Fingerprint Authentication
|
implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
|
||||||
implementation(libs.previewseekbar.media3) // SeekBar Preview
|
implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview
|
||||||
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
|
implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV
|
||||||
|
|
||||||
// Extensions & Other Libs
|
// Extensions & Other Libs
|
||||||
implementation(libs.jsoup) // HTML Parser
|
implementation("org.mozilla:rhino:1.7.15") // run JavaScript
|
||||||
implementation(libs.rhino) // Run JavaScript
|
implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance
|
||||||
implementation(libs.safefile) // To Prevent the URI File Fu*kery
|
implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery
|
||||||
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
|
implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9
|
||||||
implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
|
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit
|
||||||
implementation(libs.jackson.module.kotlin) // JSON Parser
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor
|
||||||
implementation(libs.zipline)
|
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
|
||||||
// Deprecated; will be removed once extensions have time to migrate from using it
|
Level 25 or Less. */
|
||||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
|
||||||
|
|
||||||
// Torrent Support
|
|
||||||
implementation(libs.torrentserver)
|
|
||||||
|
|
||||||
// Downloading & Networking
|
// Downloading & Networking
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation("androidx.work:work-runtime:2.9.0")
|
||||||
implementation(libs.nicehttp) // HTTP Lib
|
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") {
|
tasks.register<Jar>("androidSourcesJar") {
|
||||||
archiveClassifier.set("sources")
|
archiveClassifier.set("sources")
|
||||||
from(android.sourceSets.getByName("main").java.directories) // Full Sources
|
from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Copy>("copyJar") {
|
tasks.register<Copy>("copyJar") {
|
||||||
dependsOn("build", ":library:jvmJar")
|
|
||||||
from(
|
from(
|
||||||
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
|
"build/intermediates/compile_app_classes_jar/prereleaseDebug",
|
||||||
"../library/build/libs"
|
"../library/build/libs"
|
||||||
)
|
)
|
||||||
into("build/app-classes")
|
into("build/app-classes")
|
||||||
|
|
@ -315,39 +269,12 @@ tasks.register<Jar>("makeJar") {
|
||||||
zipTree("build/app-classes/library-jvm.jar")
|
zipTree("build/app-classes/library-jvm.jar")
|
||||||
)
|
)
|
||||||
destinationDirectory.set(layout.buildDirectory)
|
destinationDirectory.set(layout.buildDirectory)
|
||||||
archiveBaseName = "classes"
|
archivesName = "classes"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<KotlinJvmCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
compilerOptions {
|
kotlinOptions {
|
||||||
jvmTarget.set(javaTarget)
|
jvmTarget = "1.8"
|
||||||
jvmDefault.set(JvmDefaultMode.ENABLE)
|
freeCompilerArgs = listOf("-Xjvm-default=all-compatibility")
|
||||||
freeCompilerArgs.add("-Xannotation-default-target=param-property")
|
|
||||||
optIn.addAll(
|
|
||||||
"com.lagradost.cloudstream3.InternalAPI",
|
|
||||||
"com.lagradost.cloudstream3.Prerelease",
|
|
||||||
"kotlin.uuid.ExperimentalUuidApi",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
13
app/lint.xml
13
app/lint.xml
|
|
@ -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>
|
|
||||||
|
|
@ -7,7 +7,6 @@ import android.view.LayoutInflater
|
||||||
import androidx.test.core.app.ActivityScenario
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
|
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
|
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
|
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
||||||
|
|
@ -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, R.layout.activity_main_tv)
|
||||||
//testAllLayouts<ActivityMainBinding>(activity, R.layout.activity_main_tv)
|
//testAllLayouts<ActivityMainBinding>(activity, R.layout.activity_main_tv)
|
||||||
|
|
||||||
testAllLayouts<BottomResultviewPreviewBinding>(activity, R.layout.bottom_resultview_preview,R.layout.bottom_resultview_preview_tv)
|
|
||||||
|
|
||||||
testAllLayouts<FragmentPlayerBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
|
testAllLayouts<FragmentPlayerBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
|
||||||
testAllLayouts<FragmentPlayerTvBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
|
testAllLayouts<FragmentPlayerTvBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
|
||||||
|
|
||||||
|
|
@ -136,14 +133,14 @@ class ExampleInstrumentedTest {
|
||||||
@Test
|
@Test
|
||||||
@Throws(AssertionError::class)
|
@Throws(AssertionError::class)
|
||||||
fun providerCorrectData() {
|
fun providerCorrectData() {
|
||||||
val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag }
|
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
|
||||||
Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty())
|
Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
|
||||||
for (api in getAllProviders()) {
|
for (api in getAllProviders()) {
|
||||||
Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE")
|
Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE")
|
||||||
Assert.assertTrue("Api does not contain a name", api.name != "NONE")
|
Assert.assertTrue("Api does not contain a name", api.name != "NONE")
|
||||||
Assert.assertTrue(
|
Assert.assertTrue(
|
||||||
"Api ${api.name} does not contain a valid language code",
|
"Api ${api.name} does not contain a valid language code",
|
||||||
langTagsIETF.contains(api.lang)
|
isoNames.contains(api.lang)
|
||||||
)
|
)
|
||||||
Assert.assertTrue(
|
Assert.assertTrue(
|
||||||
"Api ${api.name} does not contain any supported types",
|
"Api ${api.name} does not contain any supported types",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -25,8 +25,9 @@
|
||||||
android:endY="245.72"
|
android:endY="245.72"
|
||||||
android:endX="292.58"
|
android:endX="292.58"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#3FAA11"/>
|
<item android:offset="0" android:color="#FF5D49EA"/>
|
||||||
<item android:offset="1" android:color="#39A11D"/>
|
<item android:offset="0.45" android:color="#FF452FE4"/>
|
||||||
|
<item android:offset="1" android:color="#FF2309DB"/>
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
@ -39,8 +40,9 @@
|
||||||
android:endY="245.72"
|
android:endY="245.72"
|
||||||
android:endX="248.76"
|
android:endX="248.76"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#37DB25"/>
|
<item android:offset="0" android:color="#FF4F6DFB"/>
|
||||||
<item android:offset="1" android:color="#11DD6D"/>
|
<item android:offset="0.6" android:color="#FF3559E7"/>
|
||||||
|
<item android:offset="1" android:color="#FF2149D8"/>
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
@ -53,45 +55,46 @@
|
||||||
android:endY="245.69"
|
android:endY="245.69"
|
||||||
android:endX="210.03"
|
android:endX="210.03"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#40F15D"/>
|
<item android:offset="0" android:color="#FF56B6FE"/>
|
||||||
<item android:offset="1" android:color="#42C54F"/>
|
<item android:offset="0.61" android:color="#FF599CFA"/>
|
||||||
|
<item android:offset="1" android:color="#FF5C89F7"/>
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
<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: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
|
<path
|
||||||
android:pathData="M397.78,222.69v60.93H390V222.69Z"
|
android:pathData="M397.78,222.69v60.93H390V222.69Z"
|
||||||
android:fillColor="#39A11D"/>
|
android:fillColor="#2e24ff"/>
|
||||||
<path
|
<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: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
|
<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: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
|
<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: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
|
<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: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
|
<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: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
|
<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: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
|
<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: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
|
<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: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
|
<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: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
|
<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">
|
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">
|
<aapt:attr name="android:fillColor">
|
||||||
|
|
@ -101,9 +104,9 @@
|
||||||
android:endY="252.3"
|
android:endY="252.3"
|
||||||
android:endX="373.57"
|
android:endX="373.57"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#68C671"/>
|
<item android:offset="0" android:color="#FF5D49EA"/>
|
||||||
<item android:offset="0.45" android:color="#11DD6D"/>
|
<item android:offset="0.45" android:color="#FF452FE4"/>
|
||||||
<item android:offset="1" android:color="#39A11D"/>
|
<item android:offset="1" android:color="#FF2309DB"/>
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
@ -114,9 +117,9 @@
|
||||||
android:startX="400.11"
|
android:startX="400.11"
|
||||||
android:endX="900"
|
android:endX="900"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#68C671"/>
|
<item android:offset="0" android:color="#FF5D49EA"/>
|
||||||
<item android:offset="0.45" android:color="#11DD6D"/>
|
<item android:offset="0.45" android:color="#FF452FE4"/>
|
||||||
<item android:offset="1" android:color="#39A11D"/>
|
<item android:offset="1" android:color="#FF2309DB"/>
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
@ -129,9 +132,9 @@
|
||||||
android:endY="252.3"
|
android:endY="252.3"
|
||||||
android:endX="373.57"
|
android:endX="373.57"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#68C671"/>
|
<item android:offset="0" android:color="#FF5D49EA"/>
|
||||||
<item android:offset="0.45" android:color="#11DD6D"/>
|
<item android:offset="0.45" android:color="#FF452FE4"/>
|
||||||
<item android:offset="1" android:color="#39A11D"/>
|
<item android:offset="1" android:color="#FF2309DB"/>
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
@ -142,9 +145,9 @@
|
||||||
android:startX="700.11"
|
android:startX="700.11"
|
||||||
android:endX="900.57"
|
android:endX="900.57"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#68C671"/>
|
<item android:offset="0" android:color="#FF5D49EA"/>
|
||||||
<item android:offset="0.45" android:color="#11DD6D"/>
|
<item android:offset="0.45" android:color="#FF452FE4"/>
|
||||||
<item android:offset="1" android:color="#39A11D"/>
|
<item android:offset="1" android:color="#FF2309DB"/>
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
@ -155,9 +158,9 @@
|
||||||
android:startX="400.11"
|
android:startX="400.11"
|
||||||
android:endX="800.57"
|
android:endX="800.57"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#68C671"/>
|
<item android:offset="0" android:color="#FF5D49EA"/>
|
||||||
<item android:offset="0.45" android:color="#11DD6D"/>
|
<item android:offset="0.45" android:color="#FF452FE4"/>
|
||||||
<item android:offset="1" android:color="#39A11D"/>
|
<item android:offset="1" android:color="#FF2309DB"/>
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
|
||||||
|
|
@ -7,62 +7,21 @@
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
|
<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.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.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.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.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="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="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.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" /> <!-- 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.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <!-- We can use this directly as CS3 is not on Play Store -->
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA" /> <!-- We can use to read the tv channel list -->
|
|
||||||
<!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages -->
|
<!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
|
||||||
<queries>
|
|
||||||
<!--
|
|
||||||
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
|
|
||||||
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
|
|
||||||
-->
|
|
||||||
<!-- For external video players -->
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:mimeType="video/*" />
|
|
||||||
</intent>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:mimeType="application/x-mpegURL" />
|
|
||||||
</intent>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:mimeType="application/vnd.apple.mpegurl" />
|
|
||||||
</intent>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:scheme="magnet" />
|
|
||||||
</intent>
|
|
||||||
|
|
||||||
<!-- Common players supported in actions/temp -->
|
|
||||||
<package android:name="org.videolan.vlc" />
|
|
||||||
<package android:name="org.videolan.vlc.debug" />
|
|
||||||
<package android:name="is.xyz.mpv" />
|
|
||||||
<package android:name="is.xyz.mpv.ytdl" />
|
|
||||||
<package android:name="app.marlboroadvance.mpvex" />
|
|
||||||
<package android:name="live.mehiz.mpvkt" />
|
|
||||||
<package android:name="live.mehiz.mpvkt.preview" />
|
|
||||||
<package android:name="com.brouken.player" />
|
|
||||||
<package android:name="dev.anilbeesetti.nextplayer" />
|
|
||||||
<package android:name="com.instantbits.cast.webvideo" />
|
|
||||||
<package android:name="com.gianlu.aria2android" />
|
|
||||||
|
|
||||||
<!-- Torrent clients -->
|
|
||||||
<package android:name="org.proninyaroslav.libretorrent" />
|
|
||||||
<package android:name="com.biglybt.android.client" />
|
|
||||||
</queries>
|
|
||||||
|
|
||||||
<!-- Fixes android tv fuckery -->
|
<!-- Fixes android tv fuckery -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
|
|
@ -74,8 +33,9 @@
|
||||||
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
|
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
|
||||||
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
|
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
|
||||||
<application
|
<application
|
||||||
android:name=".CloudStreamApp"
|
android:name=".AcraApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:appCategory="video"
|
android:appCategory="video"
|
||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
android:fullBackupContent="@xml/backup_descriptor"
|
android:fullBackupContent="@xml/backup_descriptor"
|
||||||
|
|
@ -83,12 +43,11 @@
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
android:pageSizeCompat="enabled"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="${target_sdk_version}">
|
tools:targetApi="tiramisu">
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
|
|
@ -106,8 +65,7 @@
|
||||||
android:screenOrientation="userLandscape"
|
android:screenOrientation="userLandscape"
|
||||||
android:supportsPictureInPicture="true"
|
android:supportsPictureInPicture="true"
|
||||||
android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
|
android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask">
|
||||||
tools:ignore="DiscouragedApi">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
|
@ -125,55 +83,19 @@
|
||||||
|
|
||||||
<data android:mimeType="*/*" />
|
<data android:mimeType="*/*" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
<!--
|
<!--
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
is a bit experimental, it makes loading repositories from browser still stay on the same page
|
is a bit experimental, it makes loading repositories from browser still stay on the same page
|
||||||
no idea about side effects
|
no idea about side effects
|
||||||
|
|
||||||
Not exported to prevent bypassing the AccountSelectActivity
|
|
||||||
-->
|
-->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true" />
|
android:supportsPictureInPicture="true">
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.account.AccountSelectActivity"
|
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter android:exported="true">
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
@ -200,14 +122,7 @@
|
||||||
|
|
||||||
<data android:scheme="cloudstreamrepo" />
|
<data android:scheme="cloudstreamrepo" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<data android:scheme="csshare" />
|
|
||||||
</intent-filter>
|
|
||||||
<!-- Allow searching with intents: cloudstreamsearch://Your%20Name -->
|
<!-- Allow searching with intents: cloudstreamsearch://Your%20Name -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
@ -231,7 +146,7 @@
|
||||||
<data android:scheme="cloudstreamcontinuewatching" />
|
<data android:scheme="cloudstreamcontinuewatching" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter android:autoVerify="false">
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
@ -244,6 +159,25 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.account.AccountSelectActivity"
|
||||||
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter android:exported="true">
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.EasterEggMonke"
|
||||||
|
android:exported="true" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.VideoDownloadRestartReceiver"
|
android:name=".receivers.VideoDownloadRestartReceiver"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
|
|
@ -254,30 +188,18 @@
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
android:name=".services.VideoDownloadService"
|
android:name=".services.VideoDownloadService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".services.DownloadQueueService"
|
|
||||||
android:enabled="true"
|
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
|
|
||||||
<service
|
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
tools:node="merge" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.ControllerActivity"
|
android:name=".ui.ControllerActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".services.PackageInstallerService"
|
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:name=".utils.PackageInstallerService"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
|
||||||
28
app/src/main/cpp/native-lib.cpp
Normal file
28
app/src/main/cpp/native-lib.cpp
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,78 +1,216 @@
|
||||||
package com.lagradost.cloudstream3
|
package com.lagradost.cloudstream3
|
||||||
|
|
||||||
/**
|
import android.app.Activity
|
||||||
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
|
import android.app.Application
|
||||||
* Use CloudStreamApp instead.
|
import android.content.Context
|
||||||
*/
|
import android.content.ContextWrapper
|
||||||
@Deprecated(
|
import android.content.Intent
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
import android.widget.Toast
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
|
import androidx.fragment.app.Fragment
|
||||||
level = DeprecationLevel.WARNING
|
import androidx.fragment.app.FragmentActivity
|
||||||
)
|
import com.lagradost.api.setContext
|
||||||
class AcraApplication {
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
companion object {
|
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(
|
class CustomReportSender : ReportSender {
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
// Sends all your crashes to google forms
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
|
override fun send(context: Context, errorContent: CrashReportData) {
|
||||||
level = DeprecationLevel.WARNING
|
println("Sending report")
|
||||||
|
val url =
|
||||||
|
"https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
|
||||||
|
val data = mapOf(
|
||||||
|
"entry.1993829403" to errorContent.toJSON()
|
||||||
)
|
)
|
||||||
val context get() = CloudStreamApp.context
|
|
||||||
|
|
||||||
@Deprecated(
|
thread { // to not run it on main thread
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
runBlocking {
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
|
suspendSafeApiCall {
|
||||||
level = DeprecationLevel.WARNING
|
app.post(url, data = data)
|
||||||
)
|
//println("Report response: $post")
|
||||||
fun removeKeys(folder: String): Int? =
|
}
|
||||||
CloudStreamApp.removeKeys(folder)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Deprecated(
|
runOnMainThread { // to run it on main looper
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
normalSafeApiCall {
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
|
Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
|
||||||
level = DeprecationLevel.WARNING
|
}
|
||||||
)
|
}
|
||||||
fun <T> setKey(path: String, value: T) =
|
}
|
||||||
CloudStreamApp.setKey(path, value)
|
}
|
||||||
|
|
||||||
@Deprecated(
|
class CustomSenderFactory : ReportSenderFactory {
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
override fun create(context: Context, config: CoreConfiguration): ReportSender {
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
|
return CustomReportSender()
|
||||||
level = DeprecationLevel.WARNING
|
}
|
||||||
)
|
|
||||||
fun <T> setKey(folder: String, path: String, value: T) =
|
override fun enabled(config: CoreConfiguration): Boolean {
|
||||||
CloudStreamApp.setKey(folder, path, value)
|
return true
|
||||||
|
}
|
||||||
@Deprecated(
|
}
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
|
class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
|
||||||
level = DeprecationLevel.WARNING
|
Thread.UncaughtExceptionHandler {
|
||||||
)
|
override fun uncaughtException(thread: Thread, error: Throwable) {
|
||||||
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
|
ACRA.errorReporter.handleException(error)
|
||||||
CloudStreamApp.getKey(path, defVal)
|
try {
|
||||||
|
PrintStream(errorFile).use { ps ->
|
||||||
@Deprecated(
|
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
|
error.printStackTrace(ps)
|
||||||
level = DeprecationLevel.WARNING
|
}
|
||||||
)
|
} catch (ignored: FileNotFoundException) {
|
||||||
inline fun <reified T : Any> getKey(path: String): T? =
|
}
|
||||||
CloudStreamApp.getKey(path)
|
try {
|
||||||
|
onError.invoke()
|
||||||
@Deprecated(
|
} catch (ignored: Exception) {
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
}
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
|
exitProcess(1)
|
||||||
level = DeprecationLevel.WARNING
|
}
|
||||||
)
|
|
||||||
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
|
}
|
||||||
CloudStreamApp.getKey(folder, path)
|
|
||||||
|
class AcraApplication : Application() {
|
||||||
@Deprecated(
|
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
override fun onCreate() {
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
|
super.onCreate()
|
||||||
level = DeprecationLevel.WARNING
|
ExceptionHandler(filesDir.resolve("last_error")) {
|
||||||
)
|
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
|
||||||
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
|
startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
||||||
CloudStreamApp.getKey(folder, path, defVal)
|
}.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()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
package com.lagradost.cloudstream3
|
package com.lagradost.cloudstream3
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.Manifest
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
|
@ -27,41 +24,30 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.core.view.isNotEmpty
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.gms.cast.framework.CastSession
|
import com.google.android.gms.cast.framework.CastSession
|
||||||
import com.google.android.material.chip.ChipGroup
|
import com.google.android.material.chip.ChipGroup
|
||||||
import com.google.android.material.navigationrail.NavigationRailView
|
import com.google.android.material.navigationrail.NavigationRailView
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||||
import com.lagradost.cloudstream3.databinding.ToastBinding
|
import com.lagradost.cloudstream3.databinding.ToastBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
import com.lagradost.cloudstream3.ui.player.PlayerEventType
|
||||||
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
|
import com.lagradost.cloudstream3.ui.result.UiText
|
||||||
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.settings.Globals.updateTv
|
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter
|
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
|
||||||
import com.lagradost.cloudstream3.utils.Event
|
import com.lagradost.cloudstream3.utils.Event
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
|
import com.lagradost.cloudstream3.utils.UIHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
|
||||||
|
|
||||||
enum class FocusDirection {
|
enum class FocusDirection {
|
||||||
Start,
|
Start,
|
||||||
|
|
@ -101,24 +87,17 @@ object CommonActivity {
|
||||||
get() {
|
get() {
|
||||||
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
||||||
}
|
}
|
||||||
val screenWidthWithOrientation: Int
|
|
||||||
get() {
|
|
||||||
return displayMetrics.widthPixels
|
|
||||||
}
|
|
||||||
val screenHeightWithOrientation: Int
|
|
||||||
get() {
|
|
||||||
return displayMetrics.heightPixels
|
|
||||||
}
|
|
||||||
|
|
||||||
var isPipDesired: Boolean = false
|
|
||||||
|
var canEnterPipMode: Boolean = false
|
||||||
|
var canShowPipMode: Boolean = false
|
||||||
var isInPIPMode: Boolean = false
|
var isInPIPMode: Boolean = false
|
||||||
|
|
||||||
val onColorSelectedEvent = Event<Pair<Int, Int>>()
|
val onColorSelectedEvent = Event<Pair<Int, Int>>()
|
||||||
val onDialogDismissedEvent = Event<Int>()
|
val onDialogDismissedEvent = Event<Int>()
|
||||||
|
|
||||||
|
var playerEventListener: ((PlayerEventType) -> Unit)? = null
|
||||||
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
||||||
var appliedTheme: Int = 0
|
|
||||||
var appliedColor: Int = 0
|
|
||||||
|
|
||||||
private var currentToast: Toast? = null
|
private var currentToast: Toast? = null
|
||||||
|
|
||||||
|
|
@ -185,41 +164,27 @@ object CommonActivity {
|
||||||
val toast = Toast(act)
|
val toast = Toast(act)
|
||||||
toast.duration = duration ?: Toast.LENGTH_SHORT
|
toast.duration = duration ?: Toast.LENGTH_SHORT
|
||||||
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
|
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
|
||||||
@Suppress("DEPRECATION")
|
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
|
currentToast = toast
|
||||||
toast.show()
|
toast.show()
|
||||||
|
|
||||||
val handler = Handler(Looper.getMainLooper())
|
|
||||||
val ref = WeakReference(toast)
|
|
||||||
|
|
||||||
/* Clean up activity leak */
|
|
||||||
handler.postDelayed({
|
|
||||||
if (ref.get() == currentToast) {
|
|
||||||
currentToast = null
|
|
||||||
}
|
|
||||||
}, 10_000)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set locale
|
* Not all languages can be fetched from locale with a code.
|
||||||
* @param languageTag shall a IETF BCP 47 conformant tag.
|
* This map allows sidestepping the default Locale(languageCode)
|
||||||
* Check [com.lagradost.cloudstream3.utils.SubtitleHelper].
|
* when setting the app language.
|
||||||
*
|
**/
|
||||||
* See locales on:
|
val appLanguageExceptions = hashMapOf(
|
||||||
* https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json
|
"zh-rTW" to Locale.TRADITIONAL_CHINESE
|
||||||
* https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
|
)
|
||||||
* https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml
|
|
||||||
* https://iso639-3.sil.org/code_tables/639/data/all
|
fun setLocale(context: Context?, languageCode: String?) {
|
||||||
*/
|
if (context == null || languageCode == null) return
|
||||||
fun setLocale(context: Context?, languageTag: String?) {
|
val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
|
||||||
if (context == null || languageTag == null) return
|
|
||||||
val locale = Locale.forLanguageTag(languageTag)
|
|
||||||
val resources: Resources = context.resources
|
val resources: Resources = context.resources
|
||||||
val config = resources.configuration
|
val config = resources.configuration
|
||||||
Locale.setDefault(locale)
|
Locale.setDefault(locale)
|
||||||
|
|
@ -227,12 +192,7 @@ object CommonActivity {
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||||
context.createConfigurationContext(config)
|
context.createConfigurationContext(config)
|
||||||
|
resources.updateConfiguration(config, resources.displayMetrics)
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
resources.updateConfiguration(
|
|
||||||
config,
|
|
||||||
resources.displayMetrics
|
|
||||||
) // FIXME this should be replaced
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.updateLocale() {
|
fun Context.updateLocale() {
|
||||||
|
|
@ -243,25 +203,28 @@ object CommonActivity {
|
||||||
|
|
||||||
fun init(act: Activity) {
|
fun init(act: Activity) {
|
||||||
setActivityInstance(act)
|
setActivityInstance(act)
|
||||||
ioSafe { Torrent.deleteAllFiles() }
|
|
||||||
val componentActivity = activity as? ComponentActivity ?: return
|
val componentActivity = activity as? ComponentActivity ?: return
|
||||||
|
|
||||||
|
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
|
||||||
|
//https://developer.android.com/guide/topics/ui/picture-in-picture
|
||||||
|
canShowPipMode =
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
|
||||||
|
componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
|
||||||
|
componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
|
||||||
|
|
||||||
componentActivity.updateLocale()
|
componentActivity.updateLocale()
|
||||||
componentActivity.updateTv()
|
componentActivity.updateTv()
|
||||||
AccountManager.initMainAPI()
|
|
||||||
NewPipe.init(DownloaderTestImpl.getInstance())
|
NewPipe.init(DownloaderTestImpl.getInstance())
|
||||||
|
|
||||||
MainActivity.activityResultLauncher =
|
MainActivity.activityResultLauncher = componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
||||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||||
val actionUid =
|
val actionUid = getKey<String>("last_click_action") ?: return@registerForActivityResult
|
||||||
getKey<String>("last_click_action") ?: return@registerForActivityResult
|
|
||||||
Log.d(TAG, "Loading action $actionUid result handler")
|
Log.d(TAG, "Loading action $actionUid result handler")
|
||||||
val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction
|
val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction ?: return@registerForActivityResult
|
||||||
?: return@registerForActivityResult
|
action.onResult(act, result.data)
|
||||||
action.onResultSafe(act, result.data)
|
|
||||||
removeKey("last_click_action")
|
removeKey("last_click_action")
|
||||||
removeKey("last_opened")
|
removeKey("last_opened_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,22 +246,17 @@ object CommonActivity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enters pip mode if it is both possible and desired to do so*/
|
|
||||||
private fun Activity.enterPIPMode() {
|
private fun Activity.enterPIPMode() {
|
||||||
if (!isPipDesired || !this.isPIPPossible()) return
|
if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
try {
|
try {
|
||||||
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
|
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
|
||||||
} catch (_: Exception) {
|
} catch (e: Exception) {
|
||||||
// Use fallback just in case
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
enterPictureInPictureMode()
|
enterPictureInPictureMode()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
enterPictureInPictureMode()
|
enterPictureInPictureMode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -307,18 +265,17 @@ object CommonActivity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onUserLeaveHint(act: Activity) {
|
fun onUserLeaveHint(act: Activity?) {
|
||||||
// On Android 12 and later we use setAutoEnterEnabled() instead.
|
if (canEnterPipMode && canShowPipMode) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return
|
act?.enterPIPMode()
|
||||||
act.enterPIPMode()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTheme(act: Activity) {
|
fun updateTheme(act: Activity) {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
|
||||||
if (settingsManager
|
if (settingsManager
|
||||||
.getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
|
.getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
|
||||||
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
) {
|
|
||||||
loadThemes(act)
|
loadThemes(act)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -350,10 +307,6 @@ object CommonActivity {
|
||||||
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
R.style.MonetMode else R.style.AppTheme
|
R.style.MonetMode else R.style.AppTheme
|
||||||
|
|
||||||
"Dracula" -> R.style.DraculaMode
|
|
||||||
"Lavender" -> R.style.LavenderMode
|
|
||||||
"SilentBlue" -> R.style.SilentBlueMode
|
|
||||||
|
|
||||||
else -> R.style.AppTheme
|
else -> R.style.AppTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,13 +339,9 @@ object CommonActivity {
|
||||||
|
|
||||||
else -> R.style.OverlayPrimaryColorNormal
|
else -> R.style.OverlayPrimaryColorNormal
|
||||||
}
|
}
|
||||||
|
|
||||||
act.theme.applyStyle(currentTheme, true)
|
act.theme.applyStyle(currentTheme, true)
|
||||||
act.theme.applyStyle(currentOverlayTheme, true)
|
act.theme.applyStyle(currentOverlayTheme, true)
|
||||||
appliedTheme = currentTheme
|
|
||||||
appliedColor = currentOverlayTheme
|
|
||||||
act.updateTv()
|
|
||||||
if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true)
|
|
||||||
act.theme.applyStyle(
|
act.theme.applyStyle(
|
||||||
R.style.LoadedStyle,
|
R.style.LoadedStyle,
|
||||||
true
|
true
|
||||||
|
|
@ -423,7 +372,8 @@ object CommonActivity {
|
||||||
|
|
||||||
private fun View.hasContent(): Boolean {
|
private fun View.hasContent(): Boolean {
|
||||||
return isShown && when (this) {
|
return isShown && when (this) {
|
||||||
is ViewGroup -> this.isNotEmpty()
|
//is RecyclerView -> this.childCount > 0
|
||||||
|
is ViewGroup -> this.childCount > 0
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -453,7 +403,7 @@ object CommonActivity {
|
||||||
// if cant focus but visible then break and let android decide
|
// if cant focus but visible then break and let android decide
|
||||||
// the exception if is the view is a parent and has children that wants focus
|
// the exception if is the view is a parent and has children that wants focus
|
||||||
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
|
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
|
||||||
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty()
|
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
|
||||||
} ?: false
|
} ?: false
|
||||||
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
|
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
|
||||||
|
|
||||||
|
|
@ -531,8 +481,84 @@ object CommonActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
|
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
|
||||||
return null
|
|
||||||
|
// 149 keycode_numpad 5
|
||||||
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
|
PlayerEventType.SeekForward
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
|
PlayerEventType.SeekBack
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
|
||||||
|
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 */
|
/** overrides focus and custom key events */
|
||||||
|
|
@ -569,7 +595,6 @@ object CommonActivity {
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
// println("NEXT FOCUS : $nextView")
|
// println("NEXT FOCUS : $nextView")
|
||||||
if (nextView != null) {
|
if (nextView != null) {
|
||||||
nextView.requestFocus()
|
nextView.requestFocus()
|
||||||
|
|
@ -577,15 +602,10 @@ object CommonActivity {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Figure out why removing the check for SearchAutoComplete seems
|
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
|
||||||
// to break focus on TV as it shouldn't need to be used.
|
|
||||||
// Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
|
|
||||||
// send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
|
|
||||||
@SuppressLint("RestrictedApi")
|
|
||||||
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
|
|
||||||
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
||||||
) {
|
) {
|
||||||
showInputMethod(act.currentFocus?.findFocus())
|
UIHelper.showInputMethod(act.currentFocus?.findFocus())
|
||||||
}
|
}
|
||||||
|
|
||||||
//println("Keycode: $keyCode")
|
//println("Keycode: $keyCode")
|
||||||
|
|
@ -594,6 +614,7 @@ object CommonActivity {
|
||||||
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
|
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
|
||||||
// Toast.LENGTH_LONG
|
// Toast.LENGTH_LONG
|
||||||
//)
|
//)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if someone else want to override the focus then don't handle the event as it is already
|
// if someone else want to override the focus then don't handle the event as it is already
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,34 @@
|
||||||
package com.lagradost.cloudstream3.actions
|
package com.lagradost.cloudstream3.actions
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.activityResultLauncher
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultFragment
|
import com.lagradost.cloudstream3.ui.result.ResultFragment
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.ui.result.UiText
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
fun updateDurationAndPosition(position: Long, duration: Long) {
|
fun updateDurationAndPosition(position: Long, duration: Long) {
|
||||||
if (position <= 0 || duration <= 0) return
|
if (position <= 0 || duration <= 0) return
|
||||||
val episode = getKey<ResultEpisode>("last_opened") ?: return
|
DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration)
|
||||||
DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
|
|
||||||
ResultFragment.updateUI()
|
ResultFragment.updateUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,13 +39,7 @@ fun updateDurationAndPosition(position: Long, duration: Long) {
|
||||||
fun makeTempM3U8Intent(
|
fun makeTempM3U8Intent(
|
||||||
context: Context,
|
context: Context,
|
||||||
intent: Intent,
|
intent: Intent,
|
||||||
result: LinkLoadingResult
|
result: LinkLoadingResult) {
|
||||||
) {
|
|
||||||
if (result.links.size == 1) {
|
|
||||||
intent.setDataAndType(result.links.first().url.toUri(), "video/*")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
intent.apply {
|
intent.apply {
|
||||||
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||||
addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
|
||||||
|
|
@ -47,11 +47,17 @@ fun makeTempM3U8Intent(
|
||||||
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
|
|
||||||
val outputFile = File.createTempFile("mirrorlist", ".m3u8", context.cacheDir)
|
val outputDir = context.cacheDir
|
||||||
|
|
||||||
|
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"
|
var text = "#EXTM3U\n#EXT-X-VERSION:3"
|
||||||
|
|
||||||
result.links.forEach { link ->
|
result.links.forEachIndexed { index, link ->
|
||||||
text += "\n#EXTINF:0,${link.name}\n${link.url}"
|
text += "\n#EXTINF:$index,${link.name}\n${link.url}"
|
||||||
}
|
}
|
||||||
|
|
||||||
//With subtitles it doesn't work for no reason :(
|
//With subtitles it doesn't work for no reason :(
|
||||||
|
|
@ -61,6 +67,7 @@ fun makeTempM3U8Intent(
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
text += "\n#EXT-X-ENDLIST"
|
text += "\n#EXT-X-ENDLIST"
|
||||||
|
|
||||||
outputFile.writeText(text)
|
outputFile.writeText(text)
|
||||||
|
|
||||||
intent.setDataAndType(
|
intent.setDataAndType(
|
||||||
|
|
@ -70,6 +77,7 @@ fun makeTempM3U8Intent(
|
||||||
outputFile
|
outputFile
|
||||||
), "application/x-mpegURL"
|
), "application/x-mpegURL"
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class OpenInAppAction(
|
abstract class OpenInAppAction(
|
||||||
|
|
@ -77,16 +85,15 @@ abstract class OpenInAppAction(
|
||||||
open val packageName: String,
|
open val packageName: String,
|
||||||
private val intentClass: String? = null,
|
private val intentClass: String? = null,
|
||||||
private val action: String = Intent.ACTION_VIEW
|
private val action: String = Intent.ACTION_VIEW
|
||||||
) : VideoClickAction() {
|
): VideoClickAction() {
|
||||||
override val name: UiText
|
override val name: UiText
|
||||||
get() = txt(R.string.episode_action_play_in_format, appName)
|
get() = txt(R.string.episode_action_play_in_format, appName)
|
||||||
|
|
||||||
override val isPlayer = true
|
override val isPlayer = true
|
||||||
|
|
||||||
override fun shouldShow(context: Context?, video: ResultEpisode?) =
|
override fun shouldShow(context: Context?, video: ResultEpisode?) = context?.isAppInstalled(packageName) == true
|
||||||
context?.isAppInstalled(packageName) != false
|
|
||||||
|
|
||||||
override suspend fun runAction(
|
override fun runAction(
|
||||||
context: Context?,
|
context: Context?,
|
||||||
video: ResultEpisode,
|
video: ResultEpisode,
|
||||||
result: LinkLoadingResult,
|
result: LinkLoadingResult,
|
||||||
|
|
@ -99,37 +106,29 @@ abstract class OpenInAppAction(
|
||||||
intent.component = ComponentName(packageName, intentClass)
|
intent.component = ComponentName(packageName, intentClass)
|
||||||
}
|
}
|
||||||
putExtra(context, intent, video, result, index)
|
putExtra(context, intent, video, result, index)
|
||||||
setKey("last_opened", video)
|
setKey("last_opened_id", video.id)
|
||||||
launchResult(intent)
|
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.
|
* Before intent is sent, this function is called to put extra data into the intent.
|
||||||
* @see VideoClickAction.runAction
|
* @see VideoClickAction.runAction
|
||||||
* */
|
* */
|
||||||
@Throws
|
abstract fun putExtra(context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int?)
|
||||||
abstract suspend 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.
|
* 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.
|
* You can use it to for example update duration and position.
|
||||||
* @see updateDurationAndPosition
|
* @see updateDurationAndPosition
|
||||||
*/
|
*/
|
||||||
@Throws
|
|
||||||
abstract fun onResult(activity: Activity, intent: Intent?)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,77 +1,32 @@
|
||||||
package com.lagradost.cloudstream3.actions
|
package com.lagradost.cloudstream3.actions
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Context
|
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.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.CopyClipboardAction
|
||||||
import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvExPackage
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
|
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
|
||||||
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
|
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
|
||||||
import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.VlcPackage
|
import com.lagradost.cloudstream3.actions.temp.VlcPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage
|
import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
|
import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
import com.lagradost.cloudstream3.ui.result.UiText
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
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
|
import kotlin.reflect.jvm.jvmName
|
||||||
|
|
||||||
object VideoClickActionHolder {
|
object VideoClickActionHolder {
|
||||||
val allVideoClickActions = atomicListOf(
|
val allVideoClickActions = threadSafeListOf<VideoClickAction>(
|
||||||
// Default
|
PlayInBrowserAction(), CopyClipboardAction(),
|
||||||
PlayInBrowserAction(),
|
VlcPackage(), ViewM3U8Action(),
|
||||||
CopyClipboardAction(),
|
MpvPackage(), MpvYTDLPackage(),
|
||||||
ViewM3U8Action(),
|
WebVideoCastPackage(), MpvKtPackage(), MpvKtPreviewPackage(),
|
||||||
PlayMirrorAction(),
|
FcastAction()
|
||||||
// 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
|
|
||||||
// ...
|
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -83,7 +38,7 @@ object VideoClickActionHolder {
|
||||||
fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions
|
fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions
|
||||||
// We need to have index before filtering
|
// We need to have index before filtering
|
||||||
.mapIndexed { id, it -> it to id + ACTION_ID_OFFSET }
|
.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 }
|
.map { it.first.name to it.second }
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,7 +54,7 @@ object VideoClickActionHolder {
|
||||||
?.second
|
?.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 {
|
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. */
|
/** Determines which plugin a given provider is from. This is the full path to the plugin. */
|
||||||
var sourcePlugin: String? = null
|
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}"
|
fun uniqueId() = "$sourcePlugin:${this::class.jvmName}"
|
||||||
|
|
||||||
@Throws
|
|
||||||
abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean
|
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.
|
* This function is called when the action is clicked.
|
||||||
* @param context The current activity
|
* @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 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
|
* @param index if oneSource is true, this is the index of the selected source
|
||||||
*/
|
*/
|
||||||
@Throws
|
abstract fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@ import android.content.Context
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
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
|
import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
|
||||||
|
|
||||||
class CopyClipboardAction: VideoClickAction() {
|
class CopyClipboardAction: VideoClickAction() {
|
||||||
|
|
@ -14,7 +14,7 @@ class CopyClipboardAction: VideoClickAction() {
|
||||||
|
|
||||||
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
|
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
|
||||||
|
|
||||||
override suspend fun runAction(
|
override fun runAction(
|
||||||
context: Context?,
|
context: Context?,
|
||||||
video: ResultEpisode,
|
video: ResultEpisode,
|
||||||
result: LinkLoadingResult,
|
result: LinkLoadingResult,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -3,12 +3,13 @@ package com.lagradost.cloudstream3.actions.temp
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
|
|
||||||
|
|
@ -33,7 +34,7 @@ open class MpvKtPackage(
|
||||||
ExtractorLinkType.M3U8
|
ExtractorLinkType.M3U8
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun putExtra(
|
override fun putExtra(
|
||||||
context: Context,
|
context: Context,
|
||||||
intent: Intent,
|
intent: Intent,
|
||||||
video: ResultEpisode,
|
video: ResultEpisode,
|
||||||
|
|
@ -44,7 +45,7 @@ open class MpvKtPackage(
|
||||||
|
|
||||||
intent.apply {
|
intent.apply {
|
||||||
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
|
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
|
||||||
setDataAndType(link.url.toUri(), "video/*")
|
setDataAndType(Uri.parse(link.url), "video/*")
|
||||||
|
|
||||||
// m3u8 plays, but changing sources feature is not available
|
// m3u8 plays, but changing sources feature is not available
|
||||||
// makeTempM3U8Intent(activity, this, result)
|
// makeTempM3U8Intent(activity, this, result)
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,13 @@ import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
|
||||||
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
|
|
||||||
// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
|
// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
|
||||||
// https://mpv-android.github.io/mpv-android/intent.html
|
// https://mpv-android.github.io/mpv-android/intent.html
|
||||||
|
|
||||||
//https://github.com/marlboro-advance/mpvEx
|
|
||||||
class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity")
|
|
||||||
|
|
||||||
class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
|
class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
|
||||||
override val sourceTypes = setOf(
|
override val sourceTypes = setOf(
|
||||||
ExtractorLinkType.VIDEO,
|
ExtractorLinkType.VIDEO,
|
||||||
|
|
@ -28,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),
|
txt(appName),
|
||||||
packageName,
|
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,
|
context: Context,
|
||||||
intent: Intent,
|
intent: Intent,
|
||||||
video: ResultEpisode,
|
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("subs", result.subs.map { it.url.toUri() }.toTypedArray())
|
||||||
putExtra("title", video.name)
|
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
|
val position = getViewPos(video.id)?.position
|
||||||
if (position != null)
|
if (position != null)
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.actions.temp
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.net.toUri
|
import android.net.Uri
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
|
|
||||||
class PlayInBrowserAction: VideoClickAction() {
|
class PlayInBrowserAction: VideoClickAction() {
|
||||||
|
|
@ -25,15 +26,19 @@ class PlayInBrowserAction: VideoClickAction() {
|
||||||
|
|
||||||
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
|
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
|
||||||
|
|
||||||
override suspend fun runAction(
|
override fun runAction(
|
||||||
context: Context?,
|
context: Context?,
|
||||||
video: ResultEpisode,
|
video: ResultEpisode,
|
||||||
result: LinkLoadingResult,
|
result: LinkLoadingResult,
|
||||||
index: Int?
|
index: Int?
|
||||||
) {
|
) {
|
||||||
val link = result.links.getOrNull(index ?: 0) ?: return
|
val link = result.links.getOrNull(index ?: 0) ?: return
|
||||||
|
try {
|
||||||
val i = Intent(Intent.ACTION_VIEW)
|
val i = Intent(Intent.ACTION_VIEW)
|
||||||
i.data = link.url.toUri()
|
i.data = Uri.parse(link.url)
|
||||||
launch(i)
|
context?.startActivity(i)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
|
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
|
|
||||||
class ViewM3U8Action: VideoClickAction() {
|
class ViewM3U8Action: VideoClickAction() {
|
||||||
override val name = txt(R.string.episode_action_play_in_format, "m3u8 player")
|
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 fun shouldShow(context: Context?, video: ResultEpisode?) = true
|
||||||
|
|
||||||
override suspend fun runAction(
|
override fun runAction(
|
||||||
context: Context?,
|
context: Context?,
|
||||||
video: ResultEpisode,
|
video: ResultEpisode,
|
||||||
result: LinkLoadingResult,
|
result: LinkLoadingResult,
|
||||||
|
|
@ -25,6 +25,6 @@ class ViewM3U8Action: VideoClickAction() {
|
||||||
if (context == null) return
|
if (context == null) return
|
||||||
val i = Intent(Intent.ACTION_VIEW)
|
val i = Intent(Intent.ACTION_VIEW)
|
||||||
makeTempM3U8Intent(context, i, result)
|
makeTempM3U8Intent(context, i, result)
|
||||||
launch(i)
|
context.startActivity(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,27 +4,21 @@ import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.lagradost.api.Log
|
import com.lagradost.api.Log
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
|
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
|
||||||
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY
|
import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
|
|
||||||
// https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
|
// https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
|
||||||
// https://wiki.videolan.org/Android_Player_Intents/
|
// https://wiki.videolan.org/Android_Player_Intents/
|
||||||
|
|
||||||
class VlcNightlyPackage : VlcPackage() {
|
class VlcPackage: OpenInAppAction(
|
||||||
override val packageName = "org.videolan.vlc.debug"
|
|
||||||
override val appName = txt("VLC Nightly")
|
|
||||||
}
|
|
||||||
|
|
||||||
open class VlcPackage: OpenInAppAction(
|
|
||||||
appName = txt("VLC"),
|
appName = txt("VLC"),
|
||||||
packageName = "org.videolan.vlc",
|
packageName = "org.videolan.vlc",
|
||||||
intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
|
@ -38,21 +32,18 @@ open class VlcPackage: OpenInAppAction(
|
||||||
Intent.ACTION_VIEW
|
Intent.ACTION_VIEW
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// while VLC supports multi links, it has poor support, so we disable it for now
|
override val oneSource = false
|
||||||
override val oneSource = true
|
|
||||||
|
|
||||||
override suspend fun putExtra(
|
override fun putExtra(
|
||||||
context: Context,
|
context: Context,
|
||||||
intent: Intent,
|
intent: Intent,
|
||||||
video: ResultEpisode,
|
video: ResultEpisode,
|
||||||
result: LinkLoadingResult,
|
result: LinkLoadingResult,
|
||||||
index: Int?
|
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
|
val position = getViewPos(video.id)?.position ?: 0L
|
||||||
|
|
||||||
intent.putExtra("from_start", false)
|
intent.putExtra("from_start", false)
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@ package com.lagradost.cloudstream3.actions.temp
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
|
|
||||||
// https://www.webvideocaster.com/integrations
|
// https://www.webvideocaster.com/integrations
|
||||||
|
|
@ -27,7 +28,7 @@ class WebVideoCastPackage: OpenInAppAction(
|
||||||
ExtractorLinkType.M3U8
|
ExtractorLinkType.M3U8
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun putExtra(
|
override fun putExtra(
|
||||||
context: Context,
|
context: Context,
|
||||||
intent: Intent,
|
intent: Intent,
|
||||||
video: ResultEpisode,
|
video: ResultEpisode,
|
||||||
|
|
@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction(
|
||||||
val link = result.links[index ?: 0]
|
val link = result.links[index ?: 0]
|
||||||
|
|
||||||
intent.apply {
|
intent.apply {
|
||||||
setDataAndType(link.url.toUri(), "video/*")
|
setDataAndType(Uri.parse(link.url), "video/*")
|
||||||
|
|
||||||
val title = video.name ?: video.headerName
|
val title = video.name ?: video.headerName
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package com.lagradost.cloudstream3.actions.temp.fcast
|
package com.lagradost.cloudstream3.actions.temp.fcast
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
|
|
@ -26,7 +26,7 @@ class FcastAction: VideoClickAction() {
|
||||||
|
|
||||||
override fun shouldShow(context: Context?, video: ResultEpisode?) = FcastManager.currentDevices.isNotEmpty()
|
override fun shouldShow(context: Context?, video: ResultEpisode?) = FcastManager.currentDevices.isNotEmpty()
|
||||||
|
|
||||||
override suspend fun runAction(
|
override fun runAction(
|
||||||
context: Context?,
|
context: Context?,
|
||||||
video: ResultEpisode,
|
video: ResultEpisode,
|
||||||
result: LinkLoadingResult,
|
result: LinkLoadingResult,
|
||||||
|
|
@ -34,7 +34,6 @@ class FcastAction: VideoClickAction() {
|
||||||
) {
|
) {
|
||||||
val link = result.links.getOrNull(index ?: 0) ?: return
|
val link = result.links.getOrNull(index ?: 0) ?: return
|
||||||
val devices = FcastManager.currentDevices.toList()
|
val devices = FcastManager.currentDevices.toList()
|
||||||
uiThread {
|
|
||||||
context?.getActivity()?.showBottomDialog(
|
context?.getActivity()?.showBottomDialog(
|
||||||
devices.map { it.name },
|
devices.map { it.name },
|
||||||
-1,
|
-1,
|
||||||
|
|
@ -45,7 +44,6 @@ class FcastAction: VideoClickAction() {
|
||||||
castTo(devices.getOrNull(it), link, position)
|
castTo(devices.getOrNull(it), link, position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun castTo(device: PublicDeviceInfo?, link: ExtractorLink, position: Long?) {
|
private fun castTo(device: PublicDeviceInfo?, link: ExtractorLink, position: Long?) {
|
||||||
|
|
@ -55,7 +53,7 @@ class FcastAction: VideoClickAction() {
|
||||||
session.sendMessage(
|
session.sendMessage(
|
||||||
Opcode.Play,
|
Opcode.Play,
|
||||||
PlayMessage(
|
PlayMessage(
|
||||||
link.type.getMimeType(),
|
"video/*",
|
||||||
link.url,
|
link.url,
|
||||||
time = position?.let { it / 1000.0 },
|
time = position?.let { it / 1000.0 },
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ import android.net.nsd.NsdManager
|
||||||
import android.net.nsd.NsdManager.ResolveListener
|
import android.net.nsd.NsdManager.ResolveListener
|
||||||
import android.net.nsd.NsdServiceInfo
|
import android.net.nsd.NsdServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ext.SdkExtensions
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
|
||||||
class FcastManager {
|
class FcastManager {
|
||||||
|
|
@ -73,50 +71,9 @@ class FcastManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
|
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
|
||||||
// Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback
|
if (serviceInfo == null) return
|
||||||
safe {
|
|
||||||
if (serviceInfo == null) return@safe
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
|
|
||||||
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 {
|
nsdManager?.resolveService(serviceInfo, object : ResolveListener {
|
||||||
override fun onResolveFailed(
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
|
||||||
serviceInfo: NsdServiceInfo?,
|
|
||||||
errorCode: Int
|
|
||||||
) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
|
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
|
||||||
|
|
@ -133,8 +90,6 @@ class FcastManager {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceLost(serviceInfo: NsdServiceInfo?) {
|
override fun onServiceLost(serviceInfo: NsdServiceInfo?) {
|
||||||
if (serviceInfo == null) return
|
if (serviceInfo == null) return
|
||||||
|
|
@ -180,16 +135,6 @@ class FcastManager {
|
||||||
|
|
||||||
class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
|
class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
|
||||||
val rawName: String = serviceInfo.serviceName
|
val rawName: String = serviceInfo.serviceName
|
||||||
val host: String? = if (
|
val host: String? = serviceInfo.host.hostAddress
|
||||||
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 name = rawName.replace("-", " ") + host?.let { " $it" }
|
val name = rawName.replace("-", " ") + host?.let { " $it" }
|
||||||
}
|
}
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
package com.lagradost.cloudstream3.metaproviders
|
package com.lagradost.cloudstream3.metaproviders
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.lagradost.cloudstream3.*
|
||||||
import com.lagradost.cloudstream3.APIHolder.apis
|
import com.lagradost.cloudstream3.APIHolder.apis
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
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.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.toNewSearchResponseList
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
|
|
@ -30,9 +22,11 @@ class CrossTmdbProvider : TmdbProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private val validApis
|
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 }
|
//.distinctBy { it.uniqueId }
|
||||||
|
|
||||||
|
|
||||||
data class CrossMetaData(
|
data class CrossMetaData(
|
||||||
@JsonProperty("isSuccess") val isSuccess: Boolean,
|
@JsonProperty("isSuccess") val isSuccess: Boolean,
|
||||||
@JsonProperty("movies") val movies: List<Pair<String, String>>? = null,
|
@JsonProperty("movies") val movies: List<Pair<String, String>>? = null,
|
||||||
|
|
@ -61,12 +55,8 @@ class CrossTmdbProvider : TmdbProvider() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(query: String, page: Int): SearchResponseList? {
|
override suspend fun search(query: String): List<SearchResponse>? {
|
||||||
// TODO REMOVE
|
return super.search(query)?.filterIsInstance<MovieSearchResponse>() // TODO REMOVE
|
||||||
return super.search(query, page)
|
|
||||||
?.items
|
|
||||||
?.filterIsInstance<MovieSearchResponse>()
|
|
||||||
?.toNewSearchResponseList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(url: String): LoadResponse? {
|
override suspend fun load(url: String): LoadResponse? {
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package com.lagradost.cloudstream3.metaproviders
|
package com.lagradost.cloudstream3.metaproviders
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.mvvm.safeAsync
|
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
|
|
||||||
object SyncRedirector {
|
object SyncRedirector {
|
||||||
|
|
@ -44,7 +44,7 @@ object SyncRedirector {
|
||||||
return syncIds.firstNotNullOfOrNull { (syncName, syncRegex) ->
|
return syncIds.firstNotNullOfOrNull { (syncName, syncRegex) ->
|
||||||
if (providerApi.supportedSyncNames.contains(syncName)) {
|
if (providerApi.supportedSyncNames.contains(syncName)) {
|
||||||
syncRegex.find(url)?.value?.let {
|
syncRegex.find(url)?.value?.let {
|
||||||
safeAsync {
|
suspendSafeApiCall {
|
||||||
providerApi.getLoadUrl(syncName, it)
|
providerApi.getLoadUrl(syncName, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,51 +1,17 @@
|
||||||
package com.lagradost.cloudstream3.metaproviders
|
package com.lagradost.cloudstream3.metaproviders
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.Actor
|
import com.lagradost.cloudstream3.*
|
||||||
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.LoadResponse.Companion.addActors
|
import com.lagradost.cloudstream3.LoadResponse.Companion.addActors
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId
|
import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
|
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.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.uwetrottmann.tmdb2.Tmdb
|
import com.uwetrottmann.tmdb2.Tmdb
|
||||||
import com.uwetrottmann.tmdb2.entities.AppendToResponse
|
import com.uwetrottmann.tmdb2.entities.*
|
||||||
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.enumerations.AppendToResponseItem
|
import com.uwetrottmann.tmdb2.enumerations.AppendToResponseItem
|
||||||
import com.uwetrottmann.tmdb2.enumerations.VideoType
|
import com.uwetrottmann.tmdb2.enumerations.VideoType
|
||||||
import retrofit2.awaitResponse
|
import retrofit2.awaitResponse
|
||||||
import retrofit2.Response
|
import java.util.*
|
||||||
import java.util.Calendar
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* episode and season starting from 1
|
* episode and season starting from 1
|
||||||
|
|
@ -88,39 +54,36 @@ open class TmdbProvider : MainAPI() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BaseTvShow.toSearchResponse(): TvSeriesSearchResponse {
|
private fun BaseTvShow.toSearchResponse(): TvSeriesSearchResponse {
|
||||||
return newTvSeriesSearchResponse(
|
return TvSeriesSearchResponse(
|
||||||
name = this.name ?: this.original_name,
|
this.name ?: this.original_name,
|
||||||
url = getUrl(id, true),
|
getUrl(id, true),
|
||||||
type = TvType.TvSeries,
|
apiName,
|
||||||
fix = false
|
TvType.TvSeries,
|
||||||
) {
|
getImageUrl(this.poster_path),
|
||||||
this.id = this@toSearchResponse.id
|
this.first_air_date?.let {
|
||||||
this.posterUrl = getImageUrl(poster_path)
|
|
||||||
this.score = Score.from10(vote_average)
|
|
||||||
this.year = first_air_date?.let {
|
|
||||||
Calendar.getInstance().apply {
|
Calendar.getInstance().apply {
|
||||||
time = it
|
time = it
|
||||||
}.get(Calendar.YEAR)
|
}.get(Calendar.YEAR)
|
||||||
}
|
},
|
||||||
}
|
null,
|
||||||
|
this.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BaseMovie.toSearchResponse(): MovieSearchResponse {
|
private fun BaseMovie.toSearchResponse(): MovieSearchResponse {
|
||||||
return newMovieSearchResponse(
|
return MovieSearchResponse(
|
||||||
name = this.title ?: this.original_title,
|
this.title ?: this.original_title,
|
||||||
url = getUrl(id, false),
|
getUrl(id, false),
|
||||||
type = TvType.Movie,
|
apiName,
|
||||||
fix = false
|
TvType.TvSeries,
|
||||||
) {
|
getImageUrl(this.poster_path),
|
||||||
this.id = this@toSearchResponse.id
|
this.release_date?.let {
|
||||||
this.posterUrl = getImageUrl(poster_path)
|
|
||||||
this.score = Score.from10(vote_average)
|
|
||||||
this.year = release_date?.let {
|
|
||||||
Calendar.getInstance().apply {
|
Calendar.getInstance().apply {
|
||||||
time = it
|
time = it
|
||||||
}.get(Calendar.YEAR)
|
}.get(Calendar.YEAR)
|
||||||
}
|
},
|
||||||
}
|
this.id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<CastMember?>?.toActors(): List<Pair<Actor, String?>>? {
|
private fun List<CastMember?>?.toActors(): List<Pair<Actor, String?>>? {
|
||||||
|
|
@ -133,39 +96,39 @@ open class TmdbProvider : MainAPI() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun TvShow.toLoadResponse(): TvSeriesLoadResponse {
|
private suspend fun TvShow.toLoadResponse(): TvSeriesLoadResponse {
|
||||||
val tvSeasonsService = tmdb.tvSeasonsService()
|
val episodes = this.seasons?.filter { !disableSeasonZero || (it.season_number ?: 0) != 0 }
|
||||||
val episodes = mutableListOf<Episode>()
|
?.mapNotNull { season ->
|
||||||
|
season.episodes?.map { episode ->
|
||||||
val validSeasons = this.seasons?.filter { !disableSeasonZero || (it.season_number ?: 0) != 0 } ?: emptyList()
|
Episode(
|
||||||
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(
|
TmdbLink(
|
||||||
episode.external_ids?.imdb_id ?: this.external_ids?.imdb_id,
|
episode.external_ids?.imdb_id ?: this.external_ids?.imdb_id,
|
||||||
this.id,
|
this.id,
|
||||||
episode.episode_number,
|
episode.episode_number,
|
||||||
episode.season_number,
|
episode.season_number,
|
||||||
this.name ?: this.original_name
|
this.name ?: this.original_name,
|
||||||
).toJson()
|
).toJson(),
|
||||||
) {
|
episode.name,
|
||||||
this.name = episode.name
|
episode.season_number,
|
||||||
this.season = episode.season_number
|
episode.episode_number,
|
||||||
this.episode = episode.episode_number
|
getImageUrl(episode.still_path),
|
||||||
this.score = Score.from10(episode.vote_average)
|
episode.rating,
|
||||||
this.description = episode.overview
|
episode.overview,
|
||||||
this.date = episode.air_date?.time
|
episode.air_date?.time,
|
||||||
this.posterUrl = getImageUrl(episode.still_path)
|
)
|
||||||
}
|
} ?: (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(
|
return newTvSeriesLoadResponse(
|
||||||
this.name ?: this.original_name,
|
this.name ?: this.original_name,
|
||||||
|
|
@ -181,13 +144,16 @@ open class TmdbProvider : MainAPI() {
|
||||||
}
|
}
|
||||||
plot = overview
|
plot = overview
|
||||||
addImdbId(external_ids?.imdb_id)
|
addImdbId(external_ids?.imdb_id)
|
||||||
|
|
||||||
tags = genres?.mapNotNull { it.name }
|
tags = genres?.mapNotNull { it.name }
|
||||||
duration = episode_run_time?.average()?.toInt()
|
duration = episode_run_time?.average()?.toInt()
|
||||||
score = Score.from10(vote_average)
|
rating = this@toLoadResponse.rating
|
||||||
addTrailer(videos.toTrailers())
|
addTrailer(videos.toTrailers())
|
||||||
|
|
||||||
recommendations = (this@toLoadResponse.recommendations
|
recommendations = (this@toLoadResponse.recommendations
|
||||||
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
|
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
|
||||||
addActors(credits?.cast?.toList().toActors())
|
addActors(credits?.cast?.toList().toActors())
|
||||||
|
|
||||||
contentRating = fetchContentRating(id, "US")
|
contentRating = fetchContentRating(id, "US")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +191,7 @@ open class TmdbProvider : MainAPI() {
|
||||||
addImdbId(external_ids?.imdb_id)
|
addImdbId(external_ids?.imdb_id)
|
||||||
tags = genres?.mapNotNull { it.name }
|
tags = genres?.mapNotNull { it.name }
|
||||||
duration = runtime
|
duration = runtime
|
||||||
score = Score.from10(vote_average)
|
rating = this@toLoadResponse.rating
|
||||||
addTrailer(videos.toTrailers())
|
addTrailer(videos.toTrailers())
|
||||||
|
|
||||||
recommendations = (this@toLoadResponse.recommendations
|
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
|
// 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()
|
// it.toSearchResponse()
|
||||||
// } ?: listOf()
|
// } ?: listOf()
|
||||||
//
|
//
|
||||||
// val popularMovies =
|
// 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()
|
// it.toSearchResponse()
|
||||||
// } ?: listOf()
|
// } ?: listOf()
|
||||||
|
|
||||||
|
|
@ -252,31 +218,31 @@ open class TmdbProvider : MainAPI() {
|
||||||
var discoverSeries: List<TvSeriesSearchResponse> = listOf()
|
var discoverSeries: List<TvSeriesSearchResponse> = listOf()
|
||||||
var topMovies: List<MovieSearchResponse> = listOf()
|
var topMovies: List<MovieSearchResponse> = listOf()
|
||||||
var topSeries: List<TvSeriesSearchResponse> = 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()
|
it.toSearchResponse()
|
||||||
} ?: listOf()
|
} ?: listOf()
|
||||||
}, {
|
}, {
|
||||||
discoverSeries = tmdb.discoverTv().page(page).build().awaitResponse().body()?.results?.map {
|
discoverSeries = tmdb.discoverTv().build().awaitResponse().body()?.results?.map {
|
||||||
it.toSearchResponse()
|
it.toSearchResponse()
|
||||||
} ?: listOf()
|
} ?: listOf()
|
||||||
}, {
|
}, {
|
||||||
// https://en.wikipedia.org/wiki/ISO_3166-1
|
// https://en.wikipedia.org/wiki/ISO_3166-1
|
||||||
topMovies =
|
topMovies =
|
||||||
tmdb.moviesService().topRated(page, "en-US", "US").awaitResponse()
|
tmdb.moviesService().topRated(1, "en-US", "US").awaitResponse()
|
||||||
.body()?.results?.map {
|
.body()?.results?.map {
|
||||||
it.toSearchResponse()
|
it.toSearchResponse()
|
||||||
} ?: listOf()
|
} ?: listOf()
|
||||||
}, {
|
}, {
|
||||||
topSeries =
|
topSeries =
|
||||||
tmdb.tvService().topRated(page, "en-US").awaitResponse().body()?.results?.map {
|
tmdb.tvService().topRated(1, "en-US").awaitResponse().body()?.results?.map {
|
||||||
it.toSearchResponse()
|
it.toSearchResponse()
|
||||||
} ?: listOf()
|
} ?: listOf()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return newHomePageResponse(
|
return HomePageResponse(
|
||||||
listOf(
|
listOf(
|
||||||
// HomePageList("Popular Series", popularSeries),
|
// HomePageList("Popular Series", popularSeries),
|
||||||
// HomePageList("Popular Movies", popularMovies),
|
// HomePageList("Popular Movies", popularMovies),
|
||||||
|
|
@ -396,27 +362,29 @@ open class TmdbProvider : MainAPI() {
|
||||||
} else {
|
} else {
|
||||||
loadFromTmdb(id)?.let { return it }
|
loadFromTmdb(id)?.let { return it }
|
||||||
if (isTvSeries) {
|
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 fromImdb = loadFromImdb(it)
|
||||||
val result = if (fromImdb == null) {
|
val result = if (fromImdb == null) {
|
||||||
val details = tmdb.tvService().tv(id, "en-US").awaitResponse().body()
|
val details = tmdb.tvService().tv(id, "en-US").awaitResponse().body()
|
||||||
loadFromImdb(it, details?.seasons ?: listOf())
|
loadFromImdb(it, details?.seasons ?: listOf())
|
||||||
?: loadFromTmdb(id, details?.seasons ?: listOf())
|
?: loadFromTmdb(id, details?.seasons ?: listOf())
|
||||||
} else fromImdb
|
} else {
|
||||||
|
fromImdb
|
||||||
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tmdb.moviesService().externalIds(id).awaitResponse()
|
tmdb.moviesService().externalIds(id, "en-US").awaitResponse()
|
||||||
.body()?.imdb_id?.let { loadFromImdb(it) }
|
.body()?.imdb_id?.let { loadFromImdb(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(query: String, page: Int): SearchResponseList? {
|
override suspend fun search(query: String): List<SearchResponse>? {
|
||||||
return tmdb.searchService().multi(query, page, "en-US", "US", includeAdult).awaitResponse()
|
return tmdb.searchService().multi(query, 1, "en-Us", "US", includeAdult).awaitResponse()
|
||||||
.body()?.results?.mapNotNull {
|
.body()?.results?.mapNotNull {
|
||||||
it.movie?.toSearchResponse() ?: it.tvShow?.toSearchResponse()
|
it.movie?.toSearchResponse() ?: it.tvShow?.toSearchResponse()
|
||||||
}?.toNewSearchResponseList()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
package com.lagradost.cloudstream3.metaproviders
|
package com.lagradost.cloudstream3.metaproviders
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import com.fasterxml.jackson.annotation.JsonAlias
|
import com.fasterxml.jackson.annotation.JsonAlias
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
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.APIHolder.unixTimeMS
|
||||||
import com.lagradost.cloudstream3.Actor
|
import com.lagradost.cloudstream3.Actor
|
||||||
import com.lagradost.cloudstream3.ActorData
|
import com.lagradost.cloudstream3.ActorData
|
||||||
|
|
@ -16,24 +17,24 @@ import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.MainPageRequest
|
import com.lagradost.cloudstream3.MainPageRequest
|
||||||
import com.lagradost.cloudstream3.NextAiring
|
import com.lagradost.cloudstream3.NextAiring
|
||||||
import com.lagradost.cloudstream3.ProviderType
|
import com.lagradost.cloudstream3.ProviderType
|
||||||
import com.lagradost.cloudstream3.Score
|
|
||||||
import com.lagradost.cloudstream3.SearchResponse
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
import com.lagradost.cloudstream3.SearchResponseList
|
|
||||||
import com.lagradost.cloudstream3.ShowStatus
|
import com.lagradost.cloudstream3.ShowStatus
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.addDate
|
import com.lagradost.cloudstream3.addDate
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.isUpcoming
|
import com.lagradost.cloudstream3.base64Decode
|
||||||
import com.lagradost.cloudstream3.mainPageOf
|
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.newHomePageResponse
|
||||||
import com.lagradost.cloudstream3.newMovieLoadResponse
|
import com.lagradost.cloudstream3.newMovieLoadResponse
|
||||||
import com.lagradost.cloudstream3.newMovieSearchResponse
|
import com.lagradost.cloudstream3.newMovieSearchResponse
|
||||||
import com.lagradost.cloudstream3.newSearchResponseList
|
|
||||||
import com.lagradost.cloudstream3.newTvSeriesLoadResponse
|
import com.lagradost.cloudstream3.newTvSeriesLoadResponse
|
||||||
import com.lagradost.cloudstream3.newTvSeriesSearchResponse
|
import com.lagradost.cloudstream3.newTvSeriesSearchResponse
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
open class TraktProvider : MainAPI() {
|
open class TraktProvider : MainAPI() {
|
||||||
override var name = "Trakt"
|
override var name = "Trakt"
|
||||||
|
|
@ -45,9 +46,9 @@ open class TraktProvider : MainAPI() {
|
||||||
TvType.Anime,
|
TvType.Anime,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val traktApiUrl = "https://api.trakt.tv"
|
private val traktClientId =
|
||||||
|
base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==")
|
||||||
private val traktClientId: String = BuildConfig.TRAKT_CLIENT_ID
|
private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2")
|
||||||
|
|
||||||
override val mainPage = mainPageOf(
|
override val mainPage = mainPageOf(
|
||||||
"$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now
|
"$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 {
|
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 ->
|
val results = parseJson<List<MediaDetails>>(apiResponse).map { element ->
|
||||||
element.toSearchResponse()
|
element.toSearchResponse()
|
||||||
|
|
@ -70,76 +72,76 @@ open class TraktProvider : MainAPI() {
|
||||||
val media = this.media ?: this
|
val media = this.media ?: this
|
||||||
val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries
|
val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries
|
||||||
val poster = media.images?.poster?.firstOrNull()
|
val poster = media.images?.poster?.firstOrNull()
|
||||||
return if (mediaType == TvType.Movie) {
|
|
||||||
newMovieSearchResponse(
|
if (mediaType == TvType.Movie) {
|
||||||
name = media.title ?: "",
|
return newMovieSearchResponse(
|
||||||
|
name = media.title!!,
|
||||||
url = Data(
|
url = Data(
|
||||||
type = mediaType,
|
type = mediaType,
|
||||||
mediaDetails = media,
|
mediaDetails = media,
|
||||||
).toJson(),
|
).toJson(),
|
||||||
type = TvType.Movie,
|
type = TvType.Movie,
|
||||||
) {
|
) {
|
||||||
score = Score.from10(media.rating)
|
|
||||||
posterUrl = fixPath(poster)
|
posterUrl = fixPath(poster)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newTvSeriesSearchResponse(
|
return newTvSeriesSearchResponse(
|
||||||
name = media.title ?: "",
|
name = media.title!!,
|
||||||
url = Data(
|
url = Data(
|
||||||
type = mediaType,
|
type = mediaType,
|
||||||
mediaDetails = media,
|
mediaDetails = media,
|
||||||
).toJson(),
|
).toJson(),
|
||||||
type = TvType.TvSeries,
|
type = TvType.TvSeries,
|
||||||
) {
|
) {
|
||||||
score = Score.from10(media.rating)
|
|
||||||
this.posterUrl = fixPath(poster)
|
this.posterUrl = fixPath(poster)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(query: String, page: Int): SearchResponseList? {
|
override suspend fun search(query: String): List<SearchResponse>? {
|
||||||
val apiResponse =
|
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()
|
element.toSearchResponse()
|
||||||
})
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(url: String): LoadResponse {
|
override suspend fun load(url: String): LoadResponse {
|
||||||
|
|
||||||
val data = parseJson<Data>(url)
|
val data = parseJson<Data>(url)
|
||||||
val mediaDetails = data.mediaDetails
|
val mediaDetails = data.mediaDetails
|
||||||
val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows"
|
val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows"
|
||||||
|
|
||||||
val posterUrl = fixPath(mediaDetails?.images?.poster?.firstOrNull())
|
val posterUrl = mediaDetails?.images?.poster?.firstOrNull()
|
||||||
val backDropUrl = fixPath(mediaDetails?.images?.fanart?.firstOrNull())
|
val backDropUrl = mediaDetails?.images?.fanart?.firstOrNull()
|
||||||
val logoUrl = fixPath(mediaDetails?.images?.logo?.firstOrNull())
|
|
||||||
|
|
||||||
val resActor =
|
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 {
|
val actors = parseJson<People>(resActor).cast?.map {
|
||||||
ActorData(
|
ActorData(
|
||||||
Actor(
|
Actor(
|
||||||
name = it.person?.name!!,
|
name = it.person?.name!!,
|
||||||
image = fixPath(it.person.images?.headshot?.firstOrNull())
|
image = getWidthImageUrl(it.person.images?.headshot?.firstOrNull(), "w500")
|
||||||
),
|
),
|
||||||
roleString = it.character
|
roleString = it.character
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val resRelated =
|
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 relatedMedia = parseJson<List<MediaDetails>>(resRelated).map { it.toSearchResponse() }
|
||||||
|
|
||||||
val isCartoon =
|
val isCartoon =
|
||||||
mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true
|
mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true
|
||||||
val isAnime =
|
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 isAsian = !isAnime && (mediaDetails?.language == "zh" || mediaDetails?.language == "ko")
|
||||||
val isBollywood = mediaDetails?.country == "in"
|
val isBollywood = mediaDetails?.country == "in"
|
||||||
val uniqueUrl = data.mediaDetails?.ids?.trakt?.toJson() ?: data.toJson()
|
|
||||||
|
|
||||||
if (data.type == TvType.Movie) {
|
if (data.type == TvType.Movie) {
|
||||||
|
|
||||||
|
|
@ -169,21 +171,19 @@ open class TraktProvider : MainAPI() {
|
||||||
dataUrl = linkData.toJson(),
|
dataUrl = linkData.toJson(),
|
||||||
type = if (isAnime) TvType.AnimeMovie else TvType.Movie,
|
type = if (isAnime) TvType.AnimeMovie else TvType.Movie,
|
||||||
) {
|
) {
|
||||||
this.uniqueUrl = uniqueUrl
|
|
||||||
this.name = mediaDetails.title
|
this.name = mediaDetails.title
|
||||||
this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie
|
this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie
|
||||||
this.posterUrl = posterUrl
|
this.posterUrl = getOriginalWidthImageUrl(posterUrl)
|
||||||
this.year = mediaDetails.year
|
this.year = mediaDetails.year
|
||||||
this.plot = mediaDetails.overview
|
this.plot = mediaDetails.overview
|
||||||
this.score = Score.from10(mediaDetails.rating)
|
this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
|
||||||
this.tags = mediaDetails.genres
|
this.tags = mediaDetails.genres
|
||||||
this.duration = mediaDetails.runtime
|
this.duration = mediaDetails.runtime
|
||||||
this.recommendations = relatedMedia
|
this.recommendations = relatedMedia
|
||||||
this.actors = actors
|
this.actors = actors
|
||||||
this.comingSoon = isUpcoming(mediaDetails.released)
|
this.comingSoon = isUpcoming(mediaDetails.released)
|
||||||
//posterHeaders
|
//posterHeaders
|
||||||
this.backgroundPosterUrl = backDropUrl
|
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
|
||||||
this.logoUrl = logoUrl
|
|
||||||
this.contentRating = mediaDetails.certification
|
this.contentRating = mediaDetails.certification
|
||||||
addTrailer(mediaDetails.trailer)
|
addTrailer(mediaDetails.trailer)
|
||||||
addImdbId(mediaDetails.ids?.imdb)
|
addImdbId(mediaDetails.ids?.imdb)
|
||||||
|
|
@ -192,7 +192,7 @@ open class TraktProvider : MainAPI() {
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
val resSeasons =
|
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 episodes = mutableListOf<Episode>()
|
||||||
val seasons = parseJson<List<Seasons>>(resSeasons)
|
val seasons = parseJson<List<Seasons>>(resSeasons)
|
||||||
var nextAir: NextAiring? = null
|
var nextAir: NextAiring? = null
|
||||||
|
|
@ -228,16 +228,16 @@ open class TraktProvider : MainAPI() {
|
||||||
).toJson()
|
).toJson()
|
||||||
|
|
||||||
episodes.add(
|
episodes.add(
|
||||||
newEpisode(linkData.toJson()) {
|
Episode(
|
||||||
this.name = episode.title
|
data = linkData.toJson(),
|
||||||
this.season = episode.season
|
name = episode.title,
|
||||||
this.episode = episode.number
|
season = episode.season,
|
||||||
this.description = episode.overview
|
episode = episode.number,
|
||||||
this.runTime = episode.runtime
|
posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()),
|
||||||
this.posterUrl = fixPath( episode.images?.screenshot?.firstOrNull())
|
rating = episode.rating?.times(10)?.roundToInt(),
|
||||||
//this.rating = episode.rating?.times(10)?.roundToInt()
|
description = episode.overview,
|
||||||
this.score = Score.from10(episode.rating)
|
runTime = episode.runtime
|
||||||
|
).apply {
|
||||||
this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
|
this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
|
||||||
if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) {
|
if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) {
|
||||||
nextAir = NextAiring(
|
nextAir = NextAiring(
|
||||||
|
|
@ -257,15 +257,14 @@ open class TraktProvider : MainAPI() {
|
||||||
type = if (isAnime) TvType.Anime else TvType.TvSeries,
|
type = if (isAnime) TvType.Anime else TvType.TvSeries,
|
||||||
episodes = episodes
|
episodes = episodes
|
||||||
) {
|
) {
|
||||||
this.uniqueUrl = uniqueUrl
|
|
||||||
this.name = mediaDetails.title
|
this.name = mediaDetails.title
|
||||||
this.type = if (isAnime) TvType.Anime else TvType.TvSeries
|
this.type = if (isAnime) TvType.Anime else TvType.TvSeries
|
||||||
this.episodes = episodes
|
this.episodes = episodes
|
||||||
this.posterUrl = posterUrl
|
this.posterUrl = getOriginalWidthImageUrl(posterUrl)
|
||||||
this.year = mediaDetails.year
|
this.year = mediaDetails.year
|
||||||
this.plot = mediaDetails.overview
|
this.plot = mediaDetails.overview
|
||||||
this.showStatus = getStatus(mediaDetails.status)
|
this.showStatus = getStatus(mediaDetails.status)
|
||||||
this.score = Score.from10(mediaDetails.rating)
|
this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
|
||||||
this.tags = mediaDetails.genres
|
this.tags = mediaDetails.genres
|
||||||
this.duration = mediaDetails.runtime
|
this.duration = mediaDetails.runtime
|
||||||
this.recommendations = relatedMedia
|
this.recommendations = relatedMedia
|
||||||
|
|
@ -273,8 +272,7 @@ open class TraktProvider : MainAPI() {
|
||||||
this.comingSoon = isUpcoming(mediaDetails.released)
|
this.comingSoon = isUpcoming(mediaDetails.released)
|
||||||
//posterHeaders
|
//posterHeaders
|
||||||
this.nextAiring = nextAir
|
this.nextAiring = nextAir
|
||||||
this.backgroundPosterUrl = backDropUrl
|
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
|
||||||
this.logoUrl = logoUrl
|
|
||||||
this.contentRating = mediaDetails.certification
|
this.contentRating = mediaDetails.certification
|
||||||
addTrailer(mediaDetails.trailer)
|
addTrailer(mediaDetails.trailer)
|
||||||
addImdbId(mediaDetails.ids?.imdb)
|
addImdbId(mediaDetails.ids?.imdb)
|
||||||
|
|
@ -291,7 +289,18 @@ open class TraktProvider : MainAPI() {
|
||||||
"trakt-api-version" to "2",
|
"trakt-api-version" to "2",
|
||||||
"trakt-api-key" to traktClientId,
|
"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 {
|
private fun getStatus(t: String?): ShowStatus {
|
||||||
|
|
@ -307,6 +316,19 @@ open class TraktProvider : MainAPI() {
|
||||||
return "https://$url"
|
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(
|
data class Data(
|
||||||
val type: TvType? = null,
|
val type: TvType? = null,
|
||||||
val mediaDetails: MediaDetails? = null,
|
val mediaDetails: MediaDetails? = null,
|
||||||
|
|
@ -357,10 +379,10 @@ open class TraktProvider : MainAPI() {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Images(
|
data class Images(
|
||||||
@JsonProperty("poster") val poster: List<String>? = null,
|
|
||||||
@JsonProperty("fanart") val fanart: 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("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("banner") val banner: List<String>? = null,
|
||||||
@JsonProperty("thumb") val thumb: List<String>? = null,
|
@JsonProperty("thumb") val thumb: List<String>? = null,
|
||||||
@JsonProperty("screenshot") val screenshot: List<String>? = null,
|
@JsonProperty("screenshot") val screenshot: List<String>? = null,
|
||||||
|
|
@ -420,30 +442,30 @@ open class TraktProvider : MainAPI() {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LinkData(
|
data class LinkData(
|
||||||
@JsonProperty("id") val id: Int? = null,
|
val id: Int? = null,
|
||||||
@JsonProperty("trakt_id") val traktId: Int? = null,
|
val traktId: Int? = null,
|
||||||
@JsonProperty("trakt_slug") val traktSlug: String? = null,
|
val traktSlug: String? = null,
|
||||||
@JsonProperty("tmdb_id") val tmdbId: Int? = null,
|
val tmdbId: Int? = null,
|
||||||
@JsonProperty("imdb_id") val imdbId: String? = null,
|
val imdbId: String? = null,
|
||||||
@JsonProperty("tvdb_id") val tvdbId: Int? = null,
|
val tvdbId: Int? = null,
|
||||||
@JsonProperty("tvrage_id") val tvrageId: String? = null,
|
val tvrageId: String? = null,
|
||||||
@JsonProperty("type") val type: String? = null,
|
val type: String? = null,
|
||||||
@JsonProperty("season") val season: Int? = null,
|
val season: Int? = null,
|
||||||
@JsonProperty("episode") val episode: Int? = null,
|
val episode: Int? = null,
|
||||||
@JsonProperty("ani_id") val aniId: String? = null,
|
val aniId: String? = null,
|
||||||
@JsonProperty("anime_id") val animeId: String? = null,
|
val animeId: String? = null,
|
||||||
@JsonProperty("title") val title: String? = null,
|
val title: String? = null,
|
||||||
@JsonProperty("year") val year: Int? = null,
|
val year: Int? = null,
|
||||||
@JsonProperty("org_title") val orgTitle: String? = null,
|
val orgTitle: String? = null,
|
||||||
@JsonProperty("is_anime") val isAnime: Boolean = false,
|
val isAnime: Boolean = false,
|
||||||
@JsonProperty("aired_year") val airedYear: Int? = null,
|
val airedYear: Int? = null,
|
||||||
@JsonProperty("last_season") val lastSeason: Int? = null,
|
val lastSeason: Int? = null,
|
||||||
@JsonProperty("eps_title") val epsTitle: String? = null,
|
val epsTitle: String? = null,
|
||||||
@JsonProperty("jp_title") val jpTitle: String? = null,
|
val jpTitle: String? = null,
|
||||||
@JsonProperty("date") val date: String? = null,
|
val date: String? = null,
|
||||||
@JsonProperty("aired_date") val airedDate: String? = null,
|
val airedDate: String? = null,
|
||||||
@JsonProperty("is_asian") val isAsian: Boolean = false,
|
val isAsian: Boolean = false,
|
||||||
@JsonProperty("is_bollywood") val isBollywood: Boolean = false,
|
val isBollywood: Boolean = false,
|
||||||
@JsonProperty("is_cartoon") val isCartoon: Boolean = false,
|
val isCartoon: Boolean = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,68 +1,16 @@
|
||||||
package com.lagradost.cloudstream3.mvvm
|
package com.lagradost.cloudstream3.mvvm
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.core.view.doOnAttach
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
/** NOTE: Only one observer at a time per value */
|
||||||
fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||||
observeNullable(liveData) { t -> t?.run(action) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
|
||||||
fun <T> ComponentActivity.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
|
|
||||||
liveData.removeObservers(this)
|
liveData.removeObservers(this)
|
||||||
liveData.observe(this, action)
|
liveData.observe(this) { it?.let { t -> action(t) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
/** NOTE: Only one observer at a time per value */
|
||||||
fun <T, V : ViewBinding> BaseFragment<V>.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||||
observeNullable(liveData) { t -> t?.run(action) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.removeObservers(this)
|
||||||
liveData.observe(this, action)
|
liveData.observe(this) { action(it) }
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import android.webkit.CookieManager
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.debugWarning
|
import com.lagradost.cloudstream3.mvvm.debugWarning
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.nicehttp.Requests.Companion.await
|
import com.lagradost.nicehttp.Requests.Companion.await
|
||||||
import com.lagradost.nicehttp.cookies
|
import com.lagradost.nicehttp.cookies
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
@ -32,7 +32,7 @@ class CloudflareKiller : Interceptor {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Needs to clear cookies between sessions to generate new cookies.
|
// Needs to clear cookies between sessions to generate new cookies.
|
||||||
safe {
|
normalSafeApiCall {
|
||||||
// This can throw an exception on unsupported devices :(
|
// This can throw an exception on unsupported devices :(
|
||||||
CookieManager.getInstance().removeAllCookies(null)
|
CookieManager.getInstance().removeAllCookies(null)
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +77,7 @@ class CloudflareKiller : Interceptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getWebViewCookie(url: String): String? {
|
private fun getWebViewCookie(url: String): String? {
|
||||||
return safe {
|
return normalSafeApiCall {
|
||||||
CookieManager.getInstance()?.getCookie(url)
|
CookieManager.getInstance()?.getCookie(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,23 +85,3 @@ fun OkHttpClient.Builder.addQuad9Dns() = (
|
||||||
"149.112.112.112",
|
"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",
|
|
||||||
)
|
|
||||||
))
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ package com.lagradost.cloudstream3.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.lagradost.cloudstream3.Prerelease
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.nicehttp.Requests
|
import com.lagradost.nicehttp.Requests
|
||||||
import com.lagradost.nicehttp.ignoreAllSSLErrors
|
import com.lagradost.nicehttp.ignoreAllSSLErrors
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
|
|
@ -16,38 +15,14 @@ import org.conscrypt.Conscrypt
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
|
|
||||||
// Backwards compatible constructor, mark as deprecated later
|
fun Requests.initClient(context: Context): OkHttpClient {
|
||||||
fun Requests.initClient(context: Context) {
|
normalSafeApiCall { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
||||||
this.baseClient = buildDefaultClient(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Only use ignoreSSL if you know what you are doing*/
|
|
||||||
@Prerelease
|
|
||||||
fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) {
|
|
||||||
this.baseClient = buildDefaultClient(context, ignoreSSL)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Backwards compatible constructor, mark as deprecated later
|
|
||||||
fun buildDefaultClient(context: Context): OkHttpClient {
|
|
||||||
return buildDefaultClient(context, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Only use ignoreSSL if you know what you are doing*/
|
|
||||||
@Prerelease
|
|
||||||
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
|
|
||||||
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
|
||||||
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0)
|
val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0)
|
||||||
val baseClient = OkHttpClient.Builder()
|
baseClient = OkHttpClient.Builder()
|
||||||
.followRedirects(true)
|
.followRedirects(true)
|
||||||
.followSslRedirects(true)
|
.followSslRedirects(true)
|
||||||
.apply {
|
.ignoreAllSSLErrors()
|
||||||
if (ignoreSSL) {
|
|
||||||
ignoreAllSSLErrors()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.cache(
|
.cache(
|
||||||
// Note that you need to add a ResponseInterceptor to make this 100% active.
|
// Note that you need to add a ResponseInterceptor to make this 100% active.
|
||||||
// The server response dictates if and when stuff should be cached.
|
// The server response dictates if and when stuff should be cached.
|
||||||
|
|
@ -63,8 +38,6 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
|
||||||
4 -> addAdGuardDns()
|
4 -> addAdGuardDns()
|
||||||
5 -> addDNSWatchDns()
|
5 -> addDNSWatchDns()
|
||||||
6 -> addQuad9Dns()
|
6 -> addQuad9Dns()
|
||||||
7 -> addDnsSbDns()
|
|
||||||
8 -> addCanadianShieldDns()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Needs to be build as otherwise the other builders will change this object
|
// 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
|
return baseClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//val Request.cookies: Map<String, String>
|
||||||
|
// get() {
|
||||||
|
// return this.headers.getCookies("Cookie")
|
||||||
|
// }
|
||||||
|
|
||||||
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
|
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,56 @@ package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
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 android.util.Log
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||||
import kotlin.Throws
|
|
||||||
|
|
||||||
abstract class Plugin : BasePlugin() {
|
const val PLUGIN_TAG = "PluginInstance"
|
||||||
|
|
||||||
|
abstract class Plugin {
|
||||||
/**
|
/**
|
||||||
* Called when your Plugin is loaded
|
* Called when your Plugin is loaded
|
||||||
* @param context Context
|
* @param context Context
|
||||||
*/
|
*/
|
||||||
@Throws(Throwable::class)
|
@Throws(Throwable::class)
|
||||||
open fun load(context: Context) {
|
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,13 +61,32 @@ abstract class Plugin : BasePlugin() {
|
||||||
fun registerVideoClickAction(element: VideoClickAction) {
|
fun registerVideoClickAction(element: VideoClickAction) {
|
||||||
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
|
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
|
||||||
element.sourcePlugin = this.filename
|
element.sourcePlugin = this.filename
|
||||||
|
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
||||||
VideoClickActionHolder.allVideoClickActions.add(element)
|
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
|
* This will contain your resources if you specified requiresResources in gradle
|
||||||
*/
|
*/
|
||||||
var resources: Resources? = null
|
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
|
* This will add a button in the settings allowing you to add custom settings
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
package com.lagradost.cloudstream3.plugins
|
package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.*
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
|
|
@ -13,56 +10,45 @@ import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
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.APIHolder.removePluginMapping
|
||||||
import com.lagradost.cloudstream3.AllLanguagesName
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||||
import com.lagradost.cloudstream3.AutoDownloadMode
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.InternalAPI
|
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
|
||||||
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
|
|
||||||
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
|
|
||||||
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
|
|
||||||
import com.lagradost.cloudstream3.R
|
|
||||||
import com.lagradost.cloudstream3.TvType
|
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||||
import com.lagradost.cloudstream3.amap
|
|
||||||
import com.lagradost.cloudstream3.mvvm.debugPrint
|
import com.lagradost.cloudstream3.mvvm.debugPrint
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
|
import com.lagradost.cloudstream3.ui.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.REPOSITORIES_KEY
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
|
|
||||||
import com.lagradost.cloudstream3.utils.extractorApis
|
import com.lagradost.cloudstream3.utils.extractorApis
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
|
||||||
import dalvik.system.PathClassLoader
|
import dalvik.system.PathClassLoader
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStreamReader
|
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
|
// 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"
|
const val PLUGINS_KEY = "PLUGINS_KEY"
|
||||||
|
|
@ -80,7 +66,6 @@ data class PluginData(
|
||||||
@JsonProperty("filePath") val filePath: String,
|
@JsonProperty("filePath") val filePath: String,
|
||||||
@JsonProperty("version") val version: Int,
|
@JsonProperty("version") val version: Int,
|
||||||
) {
|
) {
|
||||||
@WorkerThread
|
|
||||||
fun toSitePlugin(): SitePlugin {
|
fun toSitePlugin(): SitePlugin {
|
||||||
return SitePlugin(
|
return SitePlugin(
|
||||||
this.filePath,
|
this.filePath,
|
||||||
|
|
@ -95,9 +80,7 @@ data class PluginData(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
File(this.filePath).length(),
|
File(this.filePath).length()
|
||||||
// No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute.
|
|
||||||
null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +134,7 @@ object PluginManager {
|
||||||
!it.filePath.contains(repositoryPath)
|
!it.filePath.contains(repositoryPath)
|
||||||
}
|
}
|
||||||
val file = File(repositoryPath)
|
val file = File(repositoryPath)
|
||||||
safe {
|
normalSafeApiCall {
|
||||||
if (file.exists()) file.deleteRecursively()
|
if (file.exists()) file.deleteRecursively()
|
||||||
}
|
}
|
||||||
setKey(PLUGINS_KEY, plugins)
|
setKey(PLUGINS_KEY, plugins)
|
||||||
|
|
@ -188,21 +171,22 @@ object PluginManager {
|
||||||
var currentlyLoading: String? = null
|
var currentlyLoading: String? = null
|
||||||
|
|
||||||
// Maps filepath to plugin
|
// Maps filepath to plugin
|
||||||
val plugins: MutableMap<String, BasePlugin> =
|
val plugins: MutableMap<String, Plugin> =
|
||||||
LinkedHashMap<String, BasePlugin>()
|
LinkedHashMap<String, Plugin>()
|
||||||
|
|
||||||
// Maps urls to plugin
|
// Maps urls to plugin
|
||||||
val urlPlugins: MutableMap<String, BasePlugin> =
|
val urlPlugins: MutableMap<String, Plugin> =
|
||||||
LinkedHashMap<String, BasePlugin>()
|
LinkedHashMap<String, Plugin>()
|
||||||
|
|
||||||
private val classLoaders: MutableMap<PathClassLoader, BasePlugin> =
|
private val classLoaders: MutableMap<PathClassLoader, Plugin> =
|
||||||
HashMap<PathClassLoader, BasePlugin>()
|
HashMap<PathClassLoader, Plugin>()
|
||||||
|
|
||||||
var loadedLocalPlugins = false
|
var loadedLocalPlugins = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var loadedOnlinePlugins = false
|
var loadedOnlinePlugins = false
|
||||||
private set
|
private set
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
private suspend fun maybeLoadPlugin(context: Context, file: File) {
|
private suspend fun maybeLoadPlugin(context: Context, file: File) {
|
||||||
val name = file.name
|
val name = file.name
|
||||||
|
|
@ -261,24 +245,16 @@ object PluginManager {
|
||||||
* 2. If disabled do nothing
|
* 2. If disabled do nothing
|
||||||
* 3. If outdated download and load the plugin
|
* 3. If outdated download and load the plugin
|
||||||
* 4. Else load the plugin normally
|
* 4. Else load the plugin normally
|
||||||
*
|
**/
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
fun updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
|
||||||
*/
|
|
||||||
@Suppress("FunctionName")
|
|
||||||
@InternalAPI
|
|
||||||
@Throws
|
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
|
||||||
assertNonRecursiveCallstack()
|
|
||||||
|
|
||||||
// Load all plugins as fast as possible!
|
// Load all plugins as fast as possible!
|
||||||
___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity)
|
loadAllOnlinePlugins(activity)
|
||||||
afterPluginsLoadedEvent.invoke(false)
|
afterPluginsLoadedEvent.invoke(false)
|
||||||
|
|
||||||
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
||||||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||||
|
|
||||||
val onlinePlugins = urls.toList().amap {
|
val onlinePlugins = urls.toList().apmap {
|
||||||
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
||||||
}.flatten().distinctBy { it.second.url }
|
}.flatten().distinctBy { it.second.url }
|
||||||
|
|
||||||
|
|
@ -299,7 +275,7 @@ object PluginManager {
|
||||||
|
|
||||||
val updatedPlugins = mutableListOf<String>()
|
val updatedPlugins = mutableListOf<String>()
|
||||||
|
|
||||||
outdatedPlugins.amap { pluginData ->
|
outdatedPlugins.apmap { pluginData ->
|
||||||
if (pluginData.isDisabled) {
|
if (pluginData.isDisabled) {
|
||||||
//updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name))
|
//updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name))
|
||||||
unloadPlugin(pluginData.savedData.filePath)
|
unloadPlugin(pluginData.savedData.filePath)
|
||||||
|
|
@ -307,7 +283,6 @@ object PluginManager {
|
||||||
downloadPlugin(
|
downloadPlugin(
|
||||||
activity,
|
activity,
|
||||||
pluginData.onlineData.second.url,
|
pluginData.onlineData.second.url,
|
||||||
pluginData.onlineData.second.fileHash,
|
|
||||||
pluginData.savedData.internalName,
|
pluginData.savedData.internalName,
|
||||||
File(pluginData.savedData.filePath),
|
File(pluginData.savedData.filePath),
|
||||||
true
|
true
|
||||||
|
|
@ -339,23 +314,12 @@ object PluginManager {
|
||||||
* 1. Gets all online data from online plugins repo
|
* 1. Gets all online data from online plugins repo
|
||||||
* 2. Fetch all not downloaded plugins
|
* 2. Fetch all not downloaded plugins
|
||||||
* 3. Download them and reload plugins
|
* 3. Download them and reload plugins
|
||||||
*
|
**/
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
|
||||||
*/
|
|
||||||
@Suppress("FunctionName")
|
|
||||||
@InternalAPI
|
|
||||||
@Throws
|
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
|
|
||||||
activity: Activity,
|
|
||||||
mode: AutoDownloadMode
|
|
||||||
) {
|
|
||||||
assertNonRecursiveCallstack()
|
|
||||||
|
|
||||||
val newDownloadPlugins = mutableListOf<String>()
|
val newDownloadPlugins = mutableListOf<String>()
|
||||||
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
||||||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||||
val onlinePlugins = urls.toList().amap {
|
val onlinePlugins = urls.toList().apmap {
|
||||||
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
||||||
}.flatten().distinctBy { it.second.url }
|
}.flatten().distinctBy { it.second.url }
|
||||||
|
|
||||||
|
|
@ -415,11 +379,10 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
//Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}")
|
//Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}")
|
||||||
|
|
||||||
notDownloadedPlugins.amap { pluginData ->
|
notDownloadedPlugins.apmap { pluginData ->
|
||||||
downloadPlugin(
|
downloadPlugin(
|
||||||
activity,
|
activity,
|
||||||
pluginData.onlineData.second.url,
|
pluginData.onlineData.second.url,
|
||||||
pluginData.onlineData.second.fileHash,
|
|
||||||
pluginData.savedData.internalName,
|
pluginData.savedData.internalName,
|
||||||
pluginData.onlineData.first,
|
pluginData.onlineData.first,
|
||||||
!pluginData.isDisabled
|
!pluginData.isDisabled
|
||||||
|
|
@ -441,27 +404,12 @@ object PluginManager {
|
||||||
Log.i(TAG, "Plugin download done!")
|
Log.i(TAG, "Plugin download done!")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws
|
|
||||||
private fun assertNonRecursiveCallstack() {
|
|
||||||
if (Thread.currentThread().stackTrace.any { it.methodName == "loadPlugin" }) {
|
|
||||||
throw Error("You tried to call a function that will recursively call loadPlugin, this will cause crashes or memory leaks. Do not do this, there is better ways to implement the feature than reloading plugins. Are you sure you read the compile error or docs?")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use updateAllOnlinePluginsAndLoadThem
|
* Use updateAllOnlinePluginsAndLoadThem
|
||||||
*
|
* */
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
fun loadAllOnlinePlugins(context: Context) {
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
|
||||||
*/
|
|
||||||
@Suppress("FunctionName")
|
|
||||||
@InternalAPI
|
|
||||||
@Throws
|
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
|
|
||||||
assertNonRecursiveCallstack()
|
|
||||||
|
|
||||||
// Load all plugins as fast as possible!
|
// Load all plugins as fast as possible!
|
||||||
(getPluginsOnline()).toList().amap { pluginData ->
|
(getPluginsOnline()).toList().apmap { pluginData ->
|
||||||
loadPlugin(
|
loadPlugin(
|
||||||
context,
|
context,
|
||||||
File(pluginData.filePath),
|
File(pluginData.filePath),
|
||||||
|
|
@ -472,37 +420,21 @@ object PluginManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb
|
* Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb
|
||||||
*
|
**/
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
fun hotReloadAllLocalPlugins(activity: FragmentActivity?) {
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
|
||||||
*/
|
|
||||||
@Suppress("FunctionName")
|
|
||||||
@InternalAPI
|
|
||||||
@Throws
|
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
|
|
||||||
assertNonRecursiveCallstack()
|
|
||||||
|
|
||||||
Log.d(TAG, "Reloading all local plugins!")
|
Log.d(TAG, "Reloading all local plugins!")
|
||||||
if (activity == null) return
|
if (activity == null) return
|
||||||
getPluginsLocal().forEach {
|
getPluginsLocal().forEach {
|
||||||
unloadPlugin(it.filePath)
|
unloadPlugin(it.filePath)
|
||||||
}
|
}
|
||||||
___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(activity, true)
|
loadAllLocalPlugins(activity, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
|
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
|
||||||
* and reload all pages even if they are previously valid
|
* and reload all pages even if they are previously valid
|
||||||
*
|
**/
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
|
||||||
*/
|
|
||||||
@Suppress("FunctionName")
|
|
||||||
@InternalAPI
|
|
||||||
@Throws
|
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
|
||||||
assertNonRecursiveCallstack()
|
|
||||||
|
|
||||||
val dir = File(LOCAL_PLUGINS_PATH)
|
val dir = File(LOCAL_PLUGINS_PATH)
|
||||||
|
|
||||||
if (!dir.exists()) {
|
if (!dir.exists()) {
|
||||||
|
|
@ -516,64 +448,24 @@ object PluginManager {
|
||||||
val sortedPlugins = dir.listFiles()
|
val sortedPlugins = dir.listFiles()
|
||||||
// Always sort plugins alphabetically for reproducible results
|
// 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.
|
sortedPlugins?.sortedBy { it.name }?.apmap { file ->
|
||||||
// We have to do this because on Android 14+, it otherwise gives SecurityException
|
maybeLoadPlugin(context, file)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedLocalPlugins = true
|
loadedLocalPlugins = true
|
||||||
afterPluginsLoadedEvent.invoke(forceReload)
|
afterPluginsLoadedEvent.invoke(forceReload)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return true if safe mode is enabled in any possible way. */
|
|
||||||
fun isSafeMode(): Boolean {
|
|
||||||
return checkSafeModeFile() || lastError != null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This can be used to override any extension loading to fix crashes!
|
* This can be used to override any extension loading to fix crashes!
|
||||||
* @return true if safe mode file is present
|
* @return true if safe mode file is present
|
||||||
**/
|
**/
|
||||||
fun checkSafeModeFile(): Boolean {
|
fun checkSafeModeFile(): Boolean {
|
||||||
return safe {
|
return normalSafeApiCall {
|
||||||
val folder = File(CLOUD_STREAM_FOLDER)
|
val folder = File(CLOUD_STREAM_FOLDER)
|
||||||
if (!folder.exists()) return@safe false
|
if (!folder.exists()) return@normalSafeApiCall false
|
||||||
val files = folder.listFiles { _, name ->
|
val files = folder.listFiles { _, name ->
|
||||||
name.equals("safe", ignoreCase = true)
|
name.equals("safe", ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|
@ -591,26 +483,26 @@ object PluginManager {
|
||||||
Log.i(TAG, "Loading plugin: $data")
|
Log.i(TAG, "Loading plugin: $data")
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
// In case of Android 14+ then
|
// in case of android 14 then
|
||||||
try {
|
try {
|
||||||
// Set the file as read-only and log if it fails
|
File(filePath).setReadOnly()
|
||||||
if (!file.setReadOnly()) {
|
|
||||||
Log.e(TAG, "Failed to set read-only on plugin file: ${file.name}")
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.e(TAG, "Failed to set dex as read-only")
|
Log.e(TAG, "Failed to set dex as readonly")
|
||||||
logError(t)
|
logError(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
val loader = PathClassLoader(filePath, context.classLoader)
|
val loader = PathClassLoader(filePath, context.classLoader)
|
||||||
var manifest: BasePlugin.Manifest
|
var manifest: Plugin.Manifest
|
||||||
loader.getResourceAsStream("manifest.json").use { stream ->
|
loader.getResourceAsStream("manifest.json").use { stream ->
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
Log.e(TAG, "Failed to load plugin $fileName: No manifest found")
|
Log.e(TAG, "Failed to load plugin $fileName: No manifest found")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
InputStreamReader(stream).use { reader ->
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val pluginClass: Class<*> =
|
val pluginClass: Class<*> =
|
||||||
loader.loadClass(manifest.pluginClassName) as Class<out BasePlugin?>
|
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
|
||||||
val pluginInstance: BasePlugin =
|
val pluginInstance: Plugin =
|
||||||
pluginClass.getDeclaredConstructor().newInstance() as BasePlugin
|
pluginClass.getDeclaredConstructor().newInstance() as Plugin
|
||||||
|
|
||||||
// Sets with the proper version
|
// Sets with the proper version
|
||||||
setPluginData(data.copy(version = version))
|
setPluginData(data.copy(version = version))
|
||||||
|
|
@ -645,33 +537,23 @@ object PluginManager {
|
||||||
addAssetPath.invoke(assets, file.absolutePath)
|
addAssetPath.invoke(assets, file.absolutePath)
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
(pluginInstance as? Plugin)?.resources = Resources(
|
pluginInstance.resources = Resources(
|
||||||
assets,
|
assets,
|
||||||
context.resources.displayMetrics,
|
context.resources.displayMetrics,
|
||||||
context.resources.configuration
|
context.resources.configuration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
synchronized(plugins) {
|
|
||||||
plugins[filePath] = pluginInstance
|
plugins[filePath] = pluginInstance
|
||||||
}
|
|
||||||
synchronized(classLoaders) {
|
|
||||||
classLoaders[loader] = pluginInstance
|
classLoaders[loader] = pluginInstance
|
||||||
}
|
|
||||||
synchronized(urlPlugins) {
|
|
||||||
urlPlugins[data.url ?: filePath] = pluginInstance
|
urlPlugins[data.url ?: filePath] = pluginInstance
|
||||||
}
|
|
||||||
if (pluginInstance is Plugin) {
|
|
||||||
pluginInstance.load(context)
|
pluginInstance.load(context)
|
||||||
} else {
|
|
||||||
pluginInstance.load()
|
|
||||||
}
|
|
||||||
Log.i(TAG, "Loaded plugin ${data.internalName} successfully")
|
Log.i(TAG, "Loaded plugin ${data.internalName} successfully")
|
||||||
currentlyLoading = null
|
currentlyLoading = null
|
||||||
true
|
true
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
|
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
|
||||||
showToast(
|
showToast(
|
||||||
// context.getActivity(), // we are not always on the main thread
|
context.getActivity(),
|
||||||
context.getString(R.string.plugin_load_fail).format(fileName),
|
context.getString(R.string.plugin_load_fail).format(fileName),
|
||||||
Toast.LENGTH_LONG
|
Toast.LENGTH_LONG
|
||||||
)
|
)
|
||||||
|
|
@ -695,34 +577,26 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove all registered apis
|
// remove all registered apis
|
||||||
|
synchronized(APIHolder.apis) {
|
||||||
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
||||||
removePluginMapping(it)
|
removePluginMapping(it)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
APIHolder.allProviders.withLock {
|
synchronized(APIHolder.allProviders) {
|
||||||
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
|
||||||
}
|
}
|
||||||
|
|
||||||
extractorApis.withLock {
|
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
|
||||||
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
|
||||||
|
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
||||||
|
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
|
||||||
}
|
}
|
||||||
|
|
||||||
VideoClickActionHolder.allVideoClickActions.withLock {
|
|
||||||
VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename }
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(classLoaders) {
|
|
||||||
classLoaders.values.removeIf { v -> v == plugin }
|
classLoaders.values.removeIf { v -> v == plugin }
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(plugins) {
|
|
||||||
plugins.remove(absolutePath)
|
plugins.remove(absolutePath)
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(urlPlugins) {
|
|
||||||
urlPlugins.values.removeIf { v -> v == plugin }
|
urlPlugins.values.removeIf { v -> v == plugin }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spits out a unique and safe filename based on name.
|
* Spits out a unique and safe filename based on name.
|
||||||
|
|
@ -751,27 +625,25 @@ object PluginManager {
|
||||||
suspend fun downloadPlugin(
|
suspend fun downloadPlugin(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
pluginHash: String?,
|
|
||||||
internalName: String,
|
internalName: String,
|
||||||
repositoryUrl: String,
|
repositoryUrl: String,
|
||||||
loadPlugin: Boolean
|
loadPlugin: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val file = getPluginPath(activity, internalName, repositoryUrl)
|
val file = getPluginPath(activity, internalName, repositoryUrl)
|
||||||
return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin)
|
return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun downloadPlugin(
|
suspend fun downloadPlugin(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
pluginHash: String?,
|
|
||||||
internalName: String,
|
internalName: String,
|
||||||
file: File,
|
file: File,
|
||||||
loadPlugin: Boolean,
|
loadPlugin: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
|
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
|
||||||
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
|
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
|
||||||
val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false
|
val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
|
||||||
|
|
||||||
val data = PluginData(
|
val data = PluginData(
|
||||||
internalName,
|
internalName,
|
||||||
|
|
@ -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() {
|
private fun Context.createNotificationChannel() {
|
||||||
hasCreatedNotChanel = true
|
hasCreatedNotChanel = true
|
||||||
// Create the NotificationChannel, but only on API 26+ because
|
// Create the NotificationChannel, but only on API 26+ because
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
package com.lagradost.cloudstream3.plugins
|
package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.amap
|
import com.lagradost.cloudstream3.amap
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.mvvm.safeAsync
|
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||||
import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName
|
import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName
|
||||||
import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin
|
import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||||
|
|
@ -19,19 +18,16 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.BufferedInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.AtomicMoveNotSupportedException
|
import java.io.InputStream
|
||||||
import java.nio.file.Files
|
import java.io.OutputStream
|
||||||
import java.nio.file.StandardCopyOption
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comes with the app, always available in the app, non removable.
|
* Comes with the app, always available in the app, non removable.
|
||||||
* */
|
* */
|
||||||
|
|
||||||
data class Repository(
|
data class Repository(
|
||||||
@JsonProperty("iconUrl") val iconUrl: String?,
|
|
||||||
@JsonProperty("name") val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
@JsonProperty("description") val description: String?,
|
@JsonProperty("description") val description: String?,
|
||||||
@JsonProperty("manifestVersion") val manifestVersion: Int,
|
@JsonProperty("manifestVersion") val manifestVersion: Int,
|
||||||
|
|
@ -65,12 +61,10 @@ data class SitePlugin(
|
||||||
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
|
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
|
||||||
// These types are yet to be mapped and used, ignore for now
|
// These types are yet to be mapped and used, ignore for now
|
||||||
@JsonProperty("tvTypes") val tvTypes: List<String>?,
|
@JsonProperty("tvTypes") val tvTypes: List<String>?,
|
||||||
// Most often a language tag like "en" or "zh-TW"
|
|
||||||
@JsonProperty("language") val language: String?,
|
@JsonProperty("language") val language: String?,
|
||||||
@JsonProperty("iconUrl") val iconUrl: String?,
|
@JsonProperty("iconUrl") val iconUrl: String?,
|
||||||
// Automatically generated by the gradle plugin
|
// Automatically generated by the gradle plugin
|
||||||
@JsonProperty("fileSize") val fileSize: Long?,
|
@JsonProperty("fileSize") val fileSize: Long?,
|
||||||
@JsonProperty("fileHash") val fileHash: String?,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -79,26 +73,7 @@ object RepositoryManager {
|
||||||
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
||||||
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
||||||
}
|
}
|
||||||
private val GH_REGEX =
|
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
||||||
Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
|
||||||
|
|
||||||
|
|
||||||
/** Returns a SHA-256 string of the file content.
|
|
||||||
* Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/
|
|
||||||
@WorkerThread
|
|
||||||
fun sha256(file: File): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
|
|
||||||
file.inputStream().use { fis ->
|
|
||||||
val buffer = ByteArray(8192)
|
|
||||||
var read = fis.read(buffer)
|
|
||||||
while (read != -1) {
|
|
||||||
digest.update(buffer, 0, read)
|
|
||||||
read = fis.read(buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
||||||
fun convertRawGitUrl(url: String): String {
|
fun convertRawGitUrl(url: String): String {
|
||||||
|
|
@ -119,12 +94,12 @@ object RepositoryManager {
|
||||||
else fixedUrl
|
else fixedUrl
|
||||||
}
|
}
|
||||||
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
|
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
|
||||||
safeAsync {
|
suspendSafeApiCall {
|
||||||
app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
|
app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
|
||||||
it2.headers["Location"]?.let { url ->
|
it2.headers["Location"]?.let { url ->
|
||||||
if (url.startsWith("https://cutt.ly/404")) return@safeAsync null
|
if (url.startsWith("https://cutt.ly/404")) return@suspendSafeApiCall null
|
||||||
if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null
|
if (url.removeSuffix("/") == "https://cutt.ly") return@suspendSafeApiCall null
|
||||||
return@safeAsync url
|
return@suspendSafeApiCall url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +107,7 @@ object RepositoryManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun parseRepository(url: String): Repository? {
|
suspend fun parseRepository(url: String): Repository? {
|
||||||
return safeAsync {
|
return suspendSafeApiCall {
|
||||||
// Take manifestVersion and such into account later
|
// Take manifestVersion and such into account later
|
||||||
app.get(convertRawGitUrl(url)).parsedSafe()
|
app.get(convertRawGitUrl(url)).parsedSafe()
|
||||||
}
|
}
|
||||||
|
|
@ -163,52 +138,21 @@ object RepositoryManager {
|
||||||
}.flatten()
|
}.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
suspend fun downloadPluginToFile(
|
suspend fun downloadPluginToFile(
|
||||||
context: Context,
|
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
file: File,
|
file: File
|
||||||
expectedFileHash: String?
|
|
||||||
): File? {
|
): File? {
|
||||||
return safeAsync {
|
return suspendSafeApiCall {
|
||||||
val parentDir = file.parentFile ?: return@safeAsync null
|
file.mkdirs()
|
||||||
parentDir.mkdirs()
|
|
||||||
|
|
||||||
// Prevent corrupting the plugin file if the operation fails
|
// Overwrite if exists
|
||||||
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
|
if (file.exists()) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
file.createNewFile()
|
||||||
|
|
||||||
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
||||||
|
write(body.byteStream(), file.outputStream())
|
||||||
body.byteStream().use { body ->
|
|
||||||
tempFile.outputStream().use { fileSteam ->
|
|
||||||
body.copyTo(fileSteam)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expectedFileHash != null) {
|
|
||||||
val downloadHash = sha256(tempFile)
|
|
||||||
if (expectedFileHash != downloadHash) {
|
|
||||||
tempFile.delete()
|
|
||||||
throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We prefer the operation to be atomic
|
|
||||||
try {
|
|
||||||
Files.move(
|
|
||||||
tempFile.toPath(),
|
|
||||||
file.toPath(),
|
|
||||||
StandardCopyOption.REPLACE_EXISTING,
|
|
||||||
StandardCopyOption.ATOMIC_MOVE
|
|
||||||
)
|
|
||||||
} catch (_: AtomicMoveNotSupportedException) {
|
|
||||||
Files.move(
|
|
||||||
tempFile.toPath(),
|
|
||||||
file.toPath(),
|
|
||||||
StandardCopyOption.REPLACE_EXISTING
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
file
|
file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +191,7 @@ object RepositoryManager {
|
||||||
|
|
||||||
// Unload all plugins, not using deletePlugin since we
|
// Unload all plugins, not using deletePlugin since we
|
||||||
// delete all data and files in deleteRepositoryData
|
// delete all data and files in deleteRepositoryData
|
||||||
safe {
|
normalSafeApiCall {
|
||||||
file.listFiles { plugin: File ->
|
file.listFiles { plugin: File ->
|
||||||
unloadPlugin(plugin.absolutePath)
|
unloadPlugin(plugin.absolutePath)
|
||||||
false
|
false
|
||||||
|
|
@ -256,4 +200,13 @@ object RepositoryManager {
|
||||||
|
|
||||||
PluginManager.deleteRepositoryData(file.absolutePath)
|
PluginManager.deleteRepositoryData(file.absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun write(stream: InputStream, output: OutputStream) {
|
||||||
|
val input = BufferedInputStream(stream)
|
||||||
|
val dataBuffer = ByteArray(512)
|
||||||
|
var readBytes: Int
|
||||||
|
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||||
|
output.write(dataBuffer, 0, readBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
|
|
@ -12,37 +12,52 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
object VotingApi {
|
object VotingApi { // please do not cheat the votes lol
|
||||||
|
|
||||||
private const val LOGKEY = "VotingApi"
|
private const val LOGKEY = "VotingApi"
|
||||||
private const val API_DOMAIN = "https://api.countify.xyz"
|
|
||||||
|
|
||||||
private fun transformUrl(url: String): String =
|
private const val API_DOMAIN = "https://counterapi.com/api"
|
||||||
|
|
||||||
|
private fun transformUrl(url: String): String = // dont touch or all votes get reset
|
||||||
MessageDigest
|
MessageDigest
|
||||||
.getInstance("SHA-256")
|
.getInstance("SHA-256")
|
||||||
.digest("${url}#funny-salt".toByteArray())
|
.digest("${url}#funny-salt".toByteArray())
|
||||||
.fold("") { str, it -> str + "%02x".format(it) }
|
.fold("") { str, it -> str + "%02x".format(it) }
|
||||||
|
|
||||||
suspend fun SitePlugin.getVotes(): Int = getVotes(url)
|
suspend fun SitePlugin.getVotes(): Int {
|
||||||
fun SitePlugin.hasVoted(): Boolean = hasVoted(url)
|
return getVotes(url)
|
||||||
suspend fun SitePlugin.vote(): Int = vote(url)
|
}
|
||||||
fun SitePlugin.canVote(): Boolean = canVote(this.url)
|
|
||||||
|
|
||||||
|
fun SitePlugin.hasVoted(): Boolean {
|
||||||
|
return hasVoted(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun SitePlugin.vote(): Int {
|
||||||
|
return vote(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SitePlugin.canVote(): Boolean {
|
||||||
|
return canVote(this.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin url to Int
|
||||||
private val votesCache = mutableMapOf<String, Int>()
|
private val votesCache = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
private fun getRepository(pluginUrl: String) = pluginUrl
|
||||||
|
.split("/")
|
||||||
|
.drop(2)
|
||||||
|
.take(3)
|
||||||
|
.joinToString("-")
|
||||||
|
|
||||||
private suspend fun readVote(pluginUrl: String): Int {
|
private suspend fun readVote(pluginUrl: String): Int {
|
||||||
val id = transformUrl(pluginUrl)
|
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
|
||||||
val url = "$API_DOMAIN/get-total/$id"
|
Log.d(LOGKEY, "Requesting: $url")
|
||||||
Log.d(LOGKEY, "Requesting GET: $url")
|
return app.get(url).parsedSafe<Result>()?.value ?: 0
|
||||||
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeVote(pluginUrl: String): Boolean {
|
private suspend fun writeVote(pluginUrl: String): Boolean {
|
||||||
val id = transformUrl(pluginUrl)
|
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
|
||||||
val url = "$API_DOMAIN/increment/$id"
|
Log.d(LOGKEY, "Requesting: $url")
|
||||||
Log.d(LOGKEY, "Requesting POST: $url")
|
return app.get(url).parsedSafe<Result>()?.value != null
|
||||||
return app.post(url, emptyMap<String, String>())
|
|
||||||
.parsedSafe<CountifyResult>()?.count != null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getVotes(pluginUrl: String): Int =
|
suspend fun getVotes(pluginUrl: String): Int =
|
||||||
|
|
@ -53,35 +68,31 @@ object VotingApi {
|
||||||
fun hasVoted(pluginUrl: String) =
|
fun hasVoted(pluginUrl: String) =
|
||||||
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
||||||
|
|
||||||
fun canVote(pluginUrl: String): Boolean =
|
fun canVote(pluginUrl: String): Boolean {
|
||||||
PluginManager.urlPlugins.contains(pluginUrl)
|
return PluginManager.urlPlugins.contains(pluginUrl)
|
||||||
|
}
|
||||||
|
|
||||||
private val voteLock = Mutex()
|
private val voteLock = Mutex()
|
||||||
|
|
||||||
suspend fun vote(pluginUrl: String): Int {
|
suspend fun vote(pluginUrl: String): Int {
|
||||||
|
// Prevent multiple requests at the same time.
|
||||||
voteLock.withLock {
|
voteLock.withLock {
|
||||||
if (!canVote(pluginUrl)) {
|
if (!canVote(pluginUrl)) {
|
||||||
main {
|
main {
|
||||||
Toast.makeText(
|
Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
|
||||||
context,
|
.show()
|
||||||
R.string.extension_install_first,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
return getVotes(pluginUrl)
|
return getVotes(pluginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasVoted(pluginUrl)) {
|
if (hasVoted(pluginUrl)) {
|
||||||
main {
|
main {
|
||||||
Toast.makeText(
|
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
|
||||||
context,
|
.show()
|
||||||
R.string.already_voted,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
return getVotes(pluginUrl)
|
return getVotes(pluginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (writeVote(pluginUrl)) {
|
if (writeVote(pluginUrl)) {
|
||||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
||||||
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
||||||
|
|
@ -91,8 +102,7 @@ object VotingApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class CountifyResult(
|
private data class Result(
|
||||||
val id: String? = null,
|
val value: Int?
|
||||||
val count: Int? = null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package com.lagradost.cloudstream3.services
|
package com.lagradost.cloudstream3.services
|
||||||
|
|
||||||
import android.content.Context
|
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.core.app.NotificationCompat
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
|
|
@ -84,11 +82,12 @@ class BackupWorkManager(val context: Context, workerParams: WorkerParameters) :
|
||||||
BACKUP_CHANNEL_DESCRIPTION
|
BACKUP_CHANNEL_DESCRIPTION
|
||||||
)
|
)
|
||||||
|
|
||||||
val foregroundInfo = if (SDK_INT >= 29)
|
setForeground(
|
||||||
ForegroundInfo(
|
ForegroundInfo(
|
||||||
BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
BACKUP_NOTIFICATION_ID,
|
||||||
) else ForegroundInfo(BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build())
|
backupNotificationBuilder.build()
|
||||||
setForeground(foregroundInfo)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
BackupUtils.backup(context)
|
BackupUtils.backup(context)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
package com.lagradost.cloudstream3.services
|
package com.lagradost.cloudstream3.services
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
import android.os.Build
|
||||||
import android.os.Build.VERSION.SDK_INT
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import com.lagradost.cloudstream3.*
|
import com.lagradost.cloudstream3.*
|
||||||
|
|
@ -14,7 +14,7 @@ import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
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.createNotificationChannel
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
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.getAllSubscriptions
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
||||||
.setContentTitle(context.getString(R.string.subscription_in_progress_notification))
|
.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)
|
.setProgress(0, 0, true)
|
||||||
|
|
||||||
private val updateNotificationBuilder =
|
private val updateNotificationBuilder =
|
||||||
|
|
@ -98,6 +98,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedImmutableFlag")
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
try {
|
try {
|
||||||
// println("Update subscriptions!")
|
// println("Update subscriptions!")
|
||||||
|
|
@ -107,13 +108,12 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
||||||
SUBSCRIPTION_CHANNEL_DESCRIPTION
|
SUBSCRIPTION_CHANNEL_DESCRIPTION
|
||||||
)
|
)
|
||||||
|
|
||||||
val foregroundInfo = if (SDK_INT >= 29)
|
setForeground(
|
||||||
ForegroundInfo(
|
ForegroundInfo(
|
||||||
SUBSCRIPTION_NOTIFICATION_ID,
|
SUBSCRIPTION_NOTIFICATION_ID,
|
||||||
progressNotificationBuilder.build(),
|
progressNotificationBuilder.build()
|
||||||
FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
)
|
||||||
) else ForegroundInfo(SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder.build(),)
|
)
|
||||||
setForeground(foregroundInfo)
|
|
||||||
|
|
||||||
val subscriptions = getAllSubscriptions()
|
val subscriptions = getAllSubscriptions()
|
||||||
|
|
||||||
|
|
@ -128,18 +128,18 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
||||||
updateProgress(max, progress, true)
|
updateProgress(max, progress, true)
|
||||||
|
|
||||||
// We need all plugins loaded.
|
// We need all plugins loaded.
|
||||||
PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context)
|
PluginManager.loadAllOnlinePlugins(context)
|
||||||
PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context, false)
|
PluginManager.loadAllLocalPlugins(context, false)
|
||||||
|
|
||||||
subscriptions.amap { savedData ->
|
subscriptions.apmap { savedData ->
|
||||||
try {
|
try {
|
||||||
val id = savedData.id ?: return@amap null
|
val id = savedData.id ?: return@apmap null
|
||||||
val api = getApiFromNameNull(savedData.apiName) ?: return@amap null
|
val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
|
||||||
|
|
||||||
// Reasonable timeout to prevent having this worker run forever.
|
// Reasonable timeout to prevent having this worker run forever.
|
||||||
val response = withTimeoutOrNull(60_000) {
|
val response = withTimeoutOrNull(60_000) {
|
||||||
api.load(savedData.url) as? EpisodeResponse
|
api.load(savedData.url) as? EpisodeResponse
|
||||||
} ?: return@amap null
|
} ?: return@apmap null
|
||||||
|
|
||||||
val dubPreference =
|
val dubPreference =
|
||||||
getDub(id) ?: if (
|
getDub(id) ?: if (
|
||||||
|
|
@ -183,10 +183,19 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
data = savedData.url.toUri()
|
data = savedData.url.toUri()
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
}.putExtra(MainActivity.API_NAME_EXTRA_KEY, api.name)
|
}
|
||||||
|
|
||||||
val pendingIntent =
|
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 {
|
val poster = ioWork {
|
||||||
savedData.posterUrl?.let { url ->
|
savedData.posterUrl?.let { url ->
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.services
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/** Handle notification actions such as pause/resume downloads */
|
|
||||||
class VideoDownloadService : Service() {
|
class VideoDownloadService : Service() {
|
||||||
|
|
||||||
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
@ -43,3 +42,19 @@ class VideoDownloadService : Service() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// override fun onHandleIntent(intent: Intent?) {
|
||||||
|
// if (intent != null) {
|
||||||
|
// val id = intent.getIntExtra("id", -1)
|
||||||
|
// val type = intent.getStringExtra("type")
|
||||||
|
// if (id != -1 && type != null) {
|
||||||
|
// val state = when (type) {
|
||||||
|
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
|
||||||
|
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
|
||||||
|
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
|
||||||
|
// else -> return
|
||||||
|
// }
|
||||||
|
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
package com.lagradost.cloudstream3.subtitles
|
package com.lagradost.cloudstream3.subtitles
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
|
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
|
||||||
import com.lagradost.cloudstream3.app
|
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 com.lagradost.cloudstream3.ui.player.SubtitleOrigin
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
|
|
@ -11,6 +15,32 @@ import okio.source
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
interface AbstractSubProvider {
|
||||||
|
val idPrefix: String
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
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.
|
* A builder for subtitle files.
|
||||||
* @see addUrl
|
* @see addUrl
|
||||||
|
|
@ -91,3 +121,4 @@ class SubtitleResource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AbstractSubApi : AbstractSubProvider, AuthAPI
|
||||||
|
|
@ -1,133 +1,59 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
|
import com.lagradost.cloudstream3.syncproviders.providers.*
|
||||||
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
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
abstract class AccountManager {
|
abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
companion object {
|
companion object {
|
||||||
const val NONE_ID: Int = -1
|
val malApi = MALApi(0).also { api ->
|
||||||
val malApi = MALApi()
|
LoadResponse.Companion.malIdPrefix = api.idPrefix
|
||||||
val kitsuApi = KitsuApi()
|
}
|
||||||
val aniListApi = AniListApi()
|
val aniListApi = AniListApi(0).also { api ->
|
||||||
val simklApi = SimklApi()
|
LoadResponse.Companion.aniListIdPrefix = api.idPrefix
|
||||||
val localListApi = LocalList()
|
}
|
||||||
|
val simklApi = SimklApi(0).also { api ->
|
||||||
val openSubtitlesApi = OpenSubtitlesApi()
|
LoadResponse.Companion.simklIdPrefix = api.idPrefix
|
||||||
|
}
|
||||||
|
val openSubtitlesApi = OpenSubtitlesApi(0)
|
||||||
val addic7ed = Addic7ed()
|
val addic7ed = Addic7ed()
|
||||||
val subDlApi = SubDlApi()
|
val subDlApi = SubDlApi(0)
|
||||||
|
val localListApi = LocalList()
|
||||||
val subSourceApi = SubSourceApi()
|
val subSourceApi = SubSourceApi()
|
||||||
val animeSkipApi = AnimeSkipAuth()
|
|
||||||
|
|
||||||
var cachedAccounts: MutableMap<String, Array<AuthData>>
|
// used to login via app intent
|
||||||
var cachedAccountIds: MutableMap<String, Int>
|
val OAuth2Apis
|
||||||
|
get() = listOf<OAuth2API>(
|
||||||
const val ACCOUNT_TOKEN = "auth_tokens"
|
malApi, aniListApi, simklApi
|
||||||
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() {
|
// this needs init with context and can be accessed in settings
|
||||||
val ids = mutableMapOf<String, Int>()
|
val accountManagers
|
||||||
for (api in allApis) {
|
get() = listOf(
|
||||||
ids.put(
|
malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi
|
||||||
api.idPrefix,
|
|
||||||
getKey<Int>(
|
|
||||||
ACCOUNT_IDS,
|
|
||||||
"${api.idPrefix}/${DataStoreHelper.currentAccount}",
|
|
||||||
NONE_ID
|
|
||||||
) ?: NONE_ID
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
synchronized(cachedAccountIds) {
|
|
||||||
cachedAccountIds = ids
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
// used for active syncing
|
||||||
val data = mutableMapOf<String, Array<AuthData>>()
|
val SyncApis
|
||||||
val ids = mutableMapOf<String, Int>()
|
get() = listOf(
|
||||||
for (api in allApis) {
|
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
|
||||||
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
|
val inAppAuths
|
||||||
// accessing other classes
|
get() = listOf<InAppAuthAPIManager>(
|
||||||
fun initMainAPI() {
|
openSubtitlesApi,
|
||||||
LoadResponse.malIdPrefix = malApi.idPrefix
|
subDlApi
|
||||||
LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix
|
)//, nginxApi)
|
||||||
LoadResponse.aniListIdPrefix = aniListApi.idPrefix
|
|
||||||
LoadResponse.simklIdPrefix = simklApi.idPrefix
|
|
||||||
}
|
|
||||||
|
|
||||||
val subtitleProviders = arrayOf(
|
val subtitleProviders
|
||||||
SubtitleRepo(openSubtitlesApi),
|
get() = listOf(
|
||||||
SubtitleRepo(addic7ed),
|
openSubtitlesApi,
|
||||||
SubtitleRepo(subDlApi)
|
addic7ed,
|
||||||
)
|
subDlApi,
|
||||||
val syncApis = arrayOf(
|
subSourceApi
|
||||||
SyncRepo(malApi),
|
|
||||||
SyncRepo(kitsuApi),
|
|
||||||
SyncRepo(aniListApi),
|
|
||||||
SyncRepo(simklApi),
|
|
||||||
SyncRepo(localListApi)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val APP_STRING = "cloudstreamapp"
|
const val APP_STRING = "cloudstreamapp"
|
||||||
|
|
@ -140,7 +66,12 @@ abstract class AccountManager {
|
||||||
// Instantly resume watching a show
|
// Instantly resume watching a show
|
||||||
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
|
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
|
||||||
|
|
||||||
const val APP_STRING_SHARE = "csshare"
|
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 {
|
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
||||||
var secondsLong = seconds.toLong()
|
var secondsLong = seconds.toLong()
|
||||||
|
|
@ -162,4 +93,57 @@ abstract class AccountManager {
|
||||||
return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m"
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,259 +1,20 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
interface AuthAPI {
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
val name: String
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
val icon: Int?
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
|
||||||
import com.lagradost.cloudstream3.base64Encode
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
|
|
||||||
import java.net.URI
|
|
||||||
import java.security.SecureRandom
|
|
||||||
|
|
||||||
data class AuthLoginPage(
|
val requiresLogin: Boolean
|
||||||
/** The website to open to authenticate */
|
|
||||||
val url: String,
|
|
||||||
/**
|
|
||||||
* State/control code to verify against the redirectUrl to make sure the request is valid.
|
|
||||||
* This parameter will be saved, and then used in AuthAPI::login.
|
|
||||||
* */
|
|
||||||
val payload: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AuthToken(
|
val createAccountUrl : String?
|
||||||
/**
|
|
||||||
* 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 && unixTime + marginSec >= accessTokenLifetime
|
|
||||||
|
|
||||||
fun isRefreshTokenExpired(marginSec: Long = 10L) =
|
// don't change this as all keys depend on it
|
||||||
refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime
|
val idPrefix: String
|
||||||
}
|
|
||||||
|
|
||||||
data class AuthUser(
|
// if this returns null then you are not logged in
|
||||||
/** Account display-name, can also be email if name does not exist */
|
fun loginInfo(): LoginInfo?
|
||||||
@JsonProperty("name")
|
fun logOut()
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
@Deprecated(
|
|
||||||
message = "Use APIHolder.unixTime instead",
|
|
||||||
replaceWith = ReplaceWith(
|
|
||||||
expression = "APIHolder.unixTime",
|
|
||||||
imports = ["com.lagradost.cloudstream3.APIHolder"]
|
|
||||||
),
|
|
||||||
level = DeprecationLevel.WARNING,
|
|
||||||
)
|
|
||||||
val unixTime: Long
|
|
||||||
get() = APIHolder.unixTime
|
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
message = "Use APIHolder.unixTimeMS instead",
|
|
||||||
replaceWith = ReplaceWith(
|
|
||||||
expression = "unixTimeMS",
|
|
||||||
imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"]
|
|
||||||
),
|
|
||||||
level = DeprecationLevel.WARNING,
|
|
||||||
)
|
|
||||||
val unixTimeMs: Long
|
|
||||||
get() = unixTimeMS
|
|
||||||
|
|
||||||
fun splitRedirectUrl(redirectUrl: String): Map<String, String> {
|
|
||||||
return splitQuery(
|
|
||||||
URI(
|
|
||||||
redirectUrl.replace(APP_STRING, "https").replace("/#", "?")
|
|
||||||
).toURL()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun generateCodeVerifier(): String {
|
|
||||||
// It is recommended to use a URL-safe string as code_verifier.
|
|
||||||
// See section 4 of RFC 7636 for more details.
|
|
||||||
val secureRandom = SecureRandom()
|
|
||||||
val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128
|
|
||||||
secureRandom.nextBytes(codeVerifierBytes)
|
|
||||||
return base64Encode(codeVerifierBytes).trimEnd('=')
|
|
||||||
.replace("+", "-").replace("/", "_").replace("\n", "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is this url a valid redirect url for this service? */
|
|
||||||
@Throws
|
|
||||||
open fun isValidRedirectUrl(url: String): Boolean =
|
|
||||||
redirectUrlIdentifier != null && url.contains("/$redirectUrlIdentifier")
|
|
||||||
|
|
||||||
/** OAuth2 login from a valid redirectUrl, and payload given in loginRequest */
|
|
||||||
@Throws
|
|
||||||
open suspend fun login(redirectUrl: String, payload: String?): AuthToken? =
|
|
||||||
throw NotImplementedError()
|
|
||||||
|
|
||||||
/** OAuth2 login request, asking the service to provide a url to open in the browser */
|
|
||||||
@Throws
|
|
||||||
open fun loginRequest(): AuthLoginPage? = throw NotImplementedError()
|
|
||||||
|
|
||||||
/** Pin login request, asking the service to provide an verificationUrl to display with a QR code */
|
|
||||||
@Throws
|
|
||||||
open suspend fun pinRequest(): AuthPinData? = throw NotImplementedError()
|
|
||||||
|
|
||||||
/** OAuth2 token refresh, this ensures that all token passed to other functions will be valid */
|
|
||||||
@Throws
|
|
||||||
open suspend fun refreshToken(token: AuthToken): AuthToken? = throw NotImplementedError()
|
|
||||||
|
|
||||||
/** Pin login, this will be called periodically while logging in to check if the pin has been verified by the user */
|
|
||||||
@Throws
|
|
||||||
open suspend fun login(payload: AuthPinData): AuthToken? = throw NotImplementedError()
|
|
||||||
|
|
||||||
/** In app login */
|
|
||||||
@Throws
|
|
||||||
open suspend fun login(form: AuthLoginResponse): AuthToken? = throw NotImplementedError()
|
|
||||||
|
|
||||||
/** Get the visible user account */
|
|
||||||
@Throws
|
|
||||||
open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid.
|
|
||||||
*
|
|
||||||
* Note that this will currently only be called *once* on logout,
|
|
||||||
* and as such any network issues it will fail silently, and the token will not be revoked.
|
|
||||||
**/
|
|
||||||
@Throws
|
|
||||||
open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError()
|
|
||||||
|
|
||||||
@Throws
|
|
||||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
|
||||||
fun toRepo(): AuthRepo = when (this) {
|
|
||||||
is SubtitleAPI -> SubtitleRepo(this)
|
|
||||||
is SyncAPI -> SyncRepo(this)
|
|
||||||
else -> throw NotImplementedError("Unknown inheritance from AuthAPI")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION_ERROR")
|
|
||||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
|
||||||
fun loginInfo(): LoginInfo? {
|
|
||||||
return this.toRepo().authUser()?.let { user ->
|
|
||||||
LoginInfo(
|
|
||||||
profilePicture = user.profilePicture,
|
|
||||||
name = user.name,
|
|
||||||
accountIndex = -1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
|
||||||
suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
|
|
||||||
@Suppress("DEPRECATION_ERROR")
|
|
||||||
return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
|
||||||
class LoginInfo(
|
class LoginInfo(
|
||||||
val profilePicture: String? = null,
|
val profilePicture: String? = null,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +1,45 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
import com.lagradost.cloudstream3.*
|
||||||
import com.lagradost.cloudstream3.ActorData
|
|
||||||
import com.lagradost.cloudstream3.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.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.utils.Levenshtein
|
import com.lagradost.cloudstream3.ui.result.UiText
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
/**
|
interface SyncAPI : OAuth2API {
|
||||||
* 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
|
* Set this to true if the user updates something on the list like watch status or score
|
||||||
**/
|
**/
|
||||||
open var requireLibraryRefresh: Boolean = true
|
var requireLibraryRefresh: Boolean
|
||||||
open val mainUrl: String = "NONE"
|
val mainUrl: String
|
||||||
|
|
||||||
/** 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
|
* Allows certain providers to open pages from
|
||||||
* library links.
|
* library links.
|
||||||
**/
|
**/
|
||||||
open val syncIdName: SyncIdName? = null
|
val syncIdName: SyncIdName
|
||||||
|
|
||||||
/** Modify the current status of an item */
|
/**
|
||||||
@Throws
|
-1 -> None
|
||||||
@WorkerThread
|
0 -> Watching
|
||||||
open suspend fun updateStatus(
|
1 -> Completed
|
||||||
auth: AuthData?,
|
2 -> OnHold
|
||||||
id: String,
|
3 -> Dropped
|
||||||
newStatus: AbstractSyncStatus
|
4 -> PlanToWatch
|
||||||
): Boolean = throw NotImplementedError()
|
5 -> ReWatching
|
||||||
|
*/
|
||||||
|
suspend fun score(id: String, status: AbstractSyncStatus): Boolean
|
||||||
|
|
||||||
/** Get the current status of an item */
|
suspend fun getStatus(id: String): AbstractSyncStatus?
|
||||||
@Throws
|
|
||||||
@WorkerThread
|
|
||||||
open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? =
|
|
||||||
throw NotImplementedError()
|
|
||||||
|
|
||||||
/** Get metadata about an item */
|
suspend fun getResult(id: String): SyncResult?
|
||||||
@Throws
|
|
||||||
@WorkerThread
|
|
||||||
open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError()
|
|
||||||
|
|
||||||
/** Search this service for any results for a given query */
|
suspend fun search(name: String): List<SyncSearchResult>?
|
||||||
@Throws
|
|
||||||
@WorkerThread
|
|
||||||
open suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? =
|
|
||||||
throw NotImplementedError()
|
|
||||||
|
|
||||||
/** Get the current library/bookmarks of this service */
|
suspend fun getPersonalLibrary(): LibraryMetadata?
|
||||||
@Throws
|
|
||||||
@WorkerThread
|
|
||||||
open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError()
|
|
||||||
|
|
||||||
/** Helper function, may be used in the future */
|
fun getIdFromUrl(url: String): String
|
||||||
@Throws
|
|
||||||
open fun urlToId(url: String): String? = null
|
|
||||||
|
|
||||||
data class SyncSearchResult(
|
data class SyncSearchResult(
|
||||||
override val name: String,
|
override val name: String,
|
||||||
|
|
@ -80,20 +51,23 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
override var quality: SearchQuality? = null,
|
override var quality: SearchQuality? = null,
|
||||||
override var posterHeaders: Map<String, String>? = null,
|
override var posterHeaders: Map<String, String>? = null,
|
||||||
override var id: Int? = null,
|
override var id: Int? = null,
|
||||||
override var score: Score? = null,
|
|
||||||
) : SearchResponse
|
) : SearchResponse
|
||||||
|
|
||||||
abstract class AbstractSyncStatus {
|
abstract class AbstractSyncStatus {
|
||||||
abstract var status: SyncWatchType
|
abstract var status: SyncWatchType
|
||||||
abstract var score: Score?
|
|
||||||
|
/** 1-10 */
|
||||||
|
abstract var score: Int?
|
||||||
abstract var watchedEpisodes: Int?
|
abstract var watchedEpisodes: Int?
|
||||||
abstract var isFavorite: Boolean?
|
abstract var isFavorite: Boolean?
|
||||||
abstract var maxEpisodes: Int?
|
abstract var maxEpisodes: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
data class SyncStatus(
|
data class SyncStatus(
|
||||||
override var status: SyncWatchType,
|
override var status: SyncWatchType,
|
||||||
override var score: Score?,
|
/** 1-10 */
|
||||||
|
override var score: Int?,
|
||||||
override var watchedEpisodes: Int?,
|
override var watchedEpisodes: Int?,
|
||||||
override var isFavorite: Boolean? = null,
|
override var isFavorite: Boolean? = null,
|
||||||
override var maxEpisodes: Int? = null,
|
override var maxEpisodes: Int? = null,
|
||||||
|
|
@ -106,7 +80,8 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
var totalEpisodes: Int? = null,
|
var totalEpisodes: Int? = null,
|
||||||
|
|
||||||
var title: String? = null,
|
var title: String? = null,
|
||||||
var publicScore: Score? = null,
|
/**1-1000*/
|
||||||
|
var publicScore: Int? = null,
|
||||||
/**In minutes*/
|
/**In minutes*/
|
||||||
var duration: Int? = null,
|
var duration: Int? = null,
|
||||||
var synopsis: String? = null,
|
var synopsis: String? = null,
|
||||||
|
|
@ -130,6 +105,7 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
var actors: List<ActorData>? = null,
|
var actors: List<ActorData>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
data class Page(
|
data class Page(
|
||||||
val title: UiText, var items: List<LibraryItem>
|
val title: UiText, var items: List<LibraryItem>
|
||||||
) {
|
) {
|
||||||
|
|
@ -138,14 +114,13 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
ListSorting.Query ->
|
ListSorting.Query ->
|
||||||
if (query != null) {
|
if (query != null) {
|
||||||
items.sortedBy {
|
items.sortedBy {
|
||||||
-Levenshtein.partialRatio(
|
-FuzzySearch.partialRatio(
|
||||||
query.lowercase(), it.name.lowercase()
|
query.lowercase(), it.name.lowercase()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else items
|
} else items
|
||||||
|
ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating ?: 0) }
|
||||||
ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) }
|
ListSorting.RatingLow -> items.sortedBy { (it.personalRating ?: 0) }
|
||||||
ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) }
|
|
||||||
ListSorting.AlphabeticalA -> items.sortedBy { it.name }
|
ListSorting.AlphabeticalA -> items.sortedBy { it.name }
|
||||||
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
|
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
|
||||||
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
|
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
|
||||||
|
|
@ -178,7 +153,8 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
val syncId: String,
|
val syncId: String,
|
||||||
val episodesCompleted: Int?,
|
val episodesCompleted: Int?,
|
||||||
val episodesTotal: Int?,
|
val episodesTotal: Int?,
|
||||||
val personalRating: Score?,
|
/** Out of 100 */
|
||||||
|
val personalRating: Int?,
|
||||||
val lastUpdatedUnixTime: Long?,
|
val lastUpdatedUnixTime: Long?,
|
||||||
override val apiName: String,
|
override val apiName: String,
|
||||||
override var type: TvType?,
|
override var type: TvType?,
|
||||||
|
|
@ -187,8 +163,8 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
override var quality: SearchQuality?,
|
override var quality: SearchQuality?,
|
||||||
val releaseDate: Date?,
|
val releaseDate: Date?,
|
||||||
override var id: Int? = null,
|
override var id: Int? = null,
|
||||||
val plot: String? = null,
|
val plot : String? = null,
|
||||||
override var score: Score? = null,
|
val rating: Int? = null,
|
||||||
val tags: List<String>? = null
|
val tags: List<String>? = null
|
||||||
) : SearchResponse
|
) : SearchResponse
|
||||||
}
|
}
|
||||||
|
|
@ -1,30 +1,48 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
/** Stateless safe abstraction of SyncAPI */
|
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
class SyncRepo(override val api: SyncAPI) : AuthRepo(api) {
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
val syncIdName = api.syncIdName
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
|
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
|
var requireLibraryRefresh: Boolean
|
||||||
get() = api.requireLibraryRefresh
|
get() = repo.requireLibraryRefresh
|
||||||
set(value) {
|
set(value) {
|
||||||
api.requireLibraryRefresh = value
|
repo.requireLibraryRefresh = value
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result<Boolean> =
|
suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource<Boolean> {
|
||||||
runCatching {
|
return safeApiCall { repo.score(id, status) }
|
||||||
val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus)
|
|
||||||
requireLibraryRefresh = true
|
|
||||||
status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun status(id: String): Result<SyncAPI.AbstractSyncStatus?> = runCatching {
|
suspend fun getStatus(id: String): Resource<SyncAPI.AbstractSyncStatus> {
|
||||||
api.status(freshAuth(), id)
|
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun load(id: String): Result<SyncAPI.SyncResult?> = runCatching {
|
suspend fun getResult(id: String): Resource<SyncAPI.SyncResult> {
|
||||||
api.load(freshAuth(), id)
|
return safeApiCall { repo.getResult(id) ?: throw ErrorLoadingException("No data") }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun library(): Result<SyncAPI.LibraryMetadata?> = runCatching {
|
suspend fun search(query: String): Resource<List<SyncAPI.SyncSearchResult>> {
|
||||||
api.library(freshAuth())
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,205 +1,108 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.AllLanguagesName
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName
|
import com.lagradost.cloudstream3.app
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
|
|
||||||
class Addic7ed : SubtitleAPI() {
|
class Addic7ed : AbstractSubApi {
|
||||||
override val name = "Addic7ed"
|
override val name = "Addic7ed"
|
||||||
override val idPrefix = "addic7ed"
|
override val idPrefix = "addic7ed"
|
||||||
override val requiresLogin = false
|
override val requiresLogin = false
|
||||||
|
override val icon: Nothing? = null
|
||||||
|
override val createAccountUrl: Nothing? = null
|
||||||
|
|
||||||
|
override fun loginInfo(): Nothing? = null
|
||||||
|
|
||||||
|
override fun logOut() {}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val HOST = "https://www.addic7ed.com"
|
const val HOST = "https://www.addic7ed.com"
|
||||||
const val TAG = "ADDIC7ED"
|
const val TAG = "ADDIC7ED"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.fixUrl(): String {
|
private fun fixUrl(url: String): String {
|
||||||
val url = this
|
|
||||||
return if (url.startsWith("/")) HOST + url
|
return if (url.startsWith("/")) HOST + url
|
||||||
else if (!url.startsWith("http")) "$HOST/$url"
|
else if (!url.startsWith("http")) "$HOST/$url"
|
||||||
else url
|
else url
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(
|
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity> {
|
||||||
auth: AuthData?,
|
val lang = query.lang
|
||||||
query: SubtitleSearch
|
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
|
||||||
): List<SubtitleEntity>? {
|
val queryText = query.query.trim()
|
||||||
val langTagIETF = query.lang ?: AllLanguagesName
|
|
||||||
val langNumAddic7ed =
|
|
||||||
langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0
|
|
||||||
val langName =
|
|
||||||
langTagIETF2Addic7ed[langTagIETF]?.second ?:
|
|
||||||
fromTagToEnglishLanguageName(langTagIETF) ?:
|
|
||||||
"Completed" // this bypasses language filtering
|
|
||||||
val title = query.query.trim()
|
|
||||||
val epNum = query.epNumber ?: 0
|
val epNum = query.epNumber ?: 0
|
||||||
val seasonNum = query.seasonNumber ?: 0
|
val seasonNum = query.seasonNumber ?: 0
|
||||||
val yearNum = query.year ?: 0
|
val yearNum = query.year ?: 0
|
||||||
val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title
|
|
||||||
var downloadPage = ""
|
|
||||||
|
|
||||||
fun newSubtitleEntity (
|
fun cleanResources(
|
||||||
displayName: String?,
|
results: MutableList<AbstractSubtitleEntities.SubtitleEntity>,
|
||||||
link: String?,
|
name: String,
|
||||||
|
link: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
isHearingImpaired: Boolean
|
isHearingImpaired: Boolean
|
||||||
): SubtitleEntity? {
|
) {
|
||||||
if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null
|
results.add(
|
||||||
return SubtitleEntity(
|
AbstractSubtitleEntities.SubtitleEntity(
|
||||||
idPrefix = this.idPrefix,
|
idPrefix = idPrefix,
|
||||||
name = displayName,
|
name = name,
|
||||||
lang = langTagIETF,
|
lang = queryLang.toString(),
|
||||||
data = link,
|
data = link,
|
||||||
source = this.name,
|
source = this.name,
|
||||||
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
|
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
|
||||||
epNumber = epNum,
|
epNumber = epNum,
|
||||||
seasonNumber = seasonNum,
|
seasonNumber = seasonNum,
|
||||||
year = yearNum,
|
year = yearNum,
|
||||||
headers = mapOf("referer" to "$HOST/"),
|
headers = headers,
|
||||||
isHearingImpaired = isHearingImpaired
|
isHearingImpaired = isHearingImpaired
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search")
|
val title = queryText.substringBefore("(").trim()
|
||||||
val hostDocument = response.document
|
val url = "$HOST/search.php?search=${title}&Submit=Search"
|
||||||
|
val hostDocument = app.get(url).document
|
||||||
// 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name
|
var searchResult = ""
|
||||||
if (response.url.contains("/movie/") || response.url.contains("/serie/"))
|
if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
|
||||||
downloadPage = response.url
|
else if (!hostDocument.select("table.tabel")
|
||||||
|
.isNullOrEmpty()
|
||||||
// 2nd case: found tv series ep list. Redirected to $HOST/show/1234
|
) searchResult = hostDocument.select("a:contains($title)").attr("href").toString()
|
||||||
else if (response.url.contains("/show/")) {
|
else {
|
||||||
val showId = response.url.substringAfterLast("/")
|
val show =
|
||||||
|
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
|
||||||
|
?.substringBefore(",")
|
||||||
val doc = app.get(
|
val doc = app.get(
|
||||||
"$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0",
|
"$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
|
||||||
referer = "$HOST/"
|
referer = "$HOST/"
|
||||||
).document
|
).document
|
||||||
|
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
|
||||||
// get direct subtitles links from list
|
if (node.selectFirst("td")?.text()
|
||||||
return doc.select("#season tbody tr").mapNotNull { node ->
|
?.toIntOrNull() == seasonNum && node.select("td:eq(1)")
|
||||||
if (node.select("td:eq(1)").text().toIntOrNull() == epNum)
|
.text()
|
||||||
newSubtitleEntity(
|
.toIntOrNull() == epNum
|
||||||
displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(),
|
) searchResult = fixUrl(node.select("a").attr("href"))
|
||||||
link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(),
|
|
||||||
isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty()
|
|
||||||
)
|
|
||||||
else null
|
|
||||||
}
|
}
|
||||||
// 3rd case: found several or no results. Still in $HOST/search.php?search=title
|
|
||||||
} else {// (response.url.contains("/search.php"))
|
|
||||||
downloadPage = hostDocument.select("table.tabel a").selectFirst({
|
|
||||||
// tv series
|
|
||||||
if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]"
|
|
||||||
// movie + year
|
|
||||||
else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)"
|
|
||||||
// movie
|
|
||||||
else "a[href~=movie\\/]"
|
|
||||||
}())?.attr("href")?.fixUrl() ?: return null
|
|
||||||
}
|
}
|
||||||
|
val results = mutableListOf<AbstractSubtitleEntities.SubtitleEntity>()
|
||||||
|
val document = app.get(
|
||||||
|
url = fixUrl(searchResult),
|
||||||
|
).document
|
||||||
|
|
||||||
// filter download page by language. Do not work for movies :/
|
document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node ->
|
||||||
if (downloadPage.contains("/serie/"))
|
val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${
|
||||||
downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed"
|
node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")
|
||||||
val doc = app.get(url = downloadPage).document
|
}" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}"
|
||||||
|
val link = fixUrl(node.select("a.buttonDownload").attr("href"))
|
||||||
// get subtitles links from download page
|
|
||||||
return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node ->
|
|
||||||
val displayName =
|
|
||||||
doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" +
|
|
||||||
node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration")
|
|
||||||
val link =
|
|
||||||
node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl()
|
|
||||||
val isHearingImpaired =
|
val isHearingImpaired =
|
||||||
node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty()
|
!node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
|
||||||
|
cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
|
||||||
newSubtitleEntity(displayName, link, isHearingImpaired)
|
|
||||||
}
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String {
|
||||||
auth: AuthData?,
|
return data.data
|
||||||
subtitle: SubtitleEntity
|
|
||||||
): String? {
|
|
||||||
return subtitle.data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Missing (?_?)
|
|
||||||
// Pair("2", ""),
|
|
||||||
// Pair("3", ""),
|
|
||||||
// Pair("33", ""),
|
|
||||||
// Pair("34", ""),
|
|
||||||
// Do not modify unless Addic7ed changes them!
|
|
||||||
// as they are the exact values from their website
|
|
||||||
private val langTagIETF2Addic7ed = mapOf(
|
|
||||||
"ar" to Pair("38", "Arabic"),
|
|
||||||
"az" to Pair("48", "Azerbaijani"),
|
|
||||||
"bg" to Pair("35", "Bulgarian"),
|
|
||||||
"bn" to Pair("47", "Bengali"),
|
|
||||||
"bs" to Pair("44", "Bosnian"),
|
|
||||||
"ca" to Pair("12", "Català"),
|
|
||||||
"cs" to Pair("14", "Czech"),
|
|
||||||
"cy" to Pair("65", "Welsh"),
|
|
||||||
"da" to Pair("30", "Danish"),
|
|
||||||
"de" to Pair("11", "German"),
|
|
||||||
"el" to Pair("27", "Greek"),
|
|
||||||
"en" to Pair("1", "English"),
|
|
||||||
"es-419" to Pair("6", "Spanish (Latin America)"),
|
|
||||||
"es-ar" to Pair("69", "Spanish (Argentina)"),
|
|
||||||
"es-es" to Pair("5", "Spanish (Spain)"),
|
|
||||||
"es" to Pair("4", "Spanish"),
|
|
||||||
"et" to Pair("54", "Estonian"),
|
|
||||||
"eu" to Pair("13", "Euskera"),
|
|
||||||
"fa" to Pair("43", "Persian"),
|
|
||||||
"fi" to Pair("28", "Finnish"),
|
|
||||||
"fr-ca" to Pair("53", "French (Canadian)"),
|
|
||||||
"fr" to Pair("8", "French"),
|
|
||||||
"gl" to Pair("15", "Galego"),
|
|
||||||
"he" to Pair("23", "Hebrew"),
|
|
||||||
"hi" to Pair("55", "Hindi"),
|
|
||||||
"hr" to Pair("31", "Croatian"),
|
|
||||||
"hu" to Pair("20", "Hungarian"),
|
|
||||||
"hy" to Pair("50", "Armenian"),
|
|
||||||
"id" to Pair("37", "Indonesian"),
|
|
||||||
"is" to Pair("56", "Icelandic"),
|
|
||||||
"it" to Pair("7", "Italian"),
|
|
||||||
"ja" to Pair("32", "Japanese"),
|
|
||||||
"kn" to Pair("66", "Kannada"),
|
|
||||||
"ko" to Pair("42", "Korean"),
|
|
||||||
"lt" to Pair("58", "Lithuanian"),
|
|
||||||
"lv" to Pair("57", "Latvian"),
|
|
||||||
"mk" to Pair("49", "Macedonian"),
|
|
||||||
"ml" to Pair("67", "Malayalam"),
|
|
||||||
"mr" to Pair("62", "Marathi"),
|
|
||||||
"ms" to Pair("40", "Malay"),
|
|
||||||
"nl" to Pair("17", "Dutch"),
|
|
||||||
"no" to Pair("29", "Norwegian"),
|
|
||||||
"pl" to Pair("21", "Polish"),
|
|
||||||
"pt-br" to Pair("10", "Portuguese (Brazilian)"),
|
|
||||||
"pt" to Pair("9", "Portuguese"),
|
|
||||||
"ro" to Pair("26", "Romanian"),
|
|
||||||
"ru" to Pair("19", "Russian"),
|
|
||||||
"si" to Pair("60", "Sinhala"),
|
|
||||||
"sk" to Pair("25", "Slovak"),
|
|
||||||
"sl" to Pair("22", "Slovenian"),
|
|
||||||
"sq" to Pair("52", "Albanian"),
|
|
||||||
"sr-latn" to Pair("36", "Serbian (Latin)"),
|
|
||||||
"sr" to Pair("39", "Serbian (Cyrillic)"),
|
|
||||||
"sv" to Pair("18", "Swedish"),
|
|
||||||
"ta" to Pair("59", "Tamil"),
|
|
||||||
"te" to Pair("63", "Telugu"),
|
|
||||||
"th" to Pair("46", "Thai"),
|
|
||||||
"tl" to Pair("68", "Tagalog"),
|
|
||||||
"tlh" to Pair("61", "Klingon"),
|
|
||||||
"tr" to Pair("16", "Turkish"),
|
|
||||||
"uk" to Pair("51", "Ukrainian"),
|
|
||||||
"vi" to Pair("45", "Vietnamese"),
|
|
||||||
"yue" to Pair("64", "Cantonese"),
|
|
||||||
"zh-hans" to Pair("41", "Chinese (Simplified)"),
|
|
||||||
"zh-hant" to Pair("24", "Chinese (Traditional)"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,93 +1,93 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.Actor
|
import com.lagradost.cloudstream3.*
|
||||||
import com.lagradost.cloudstream3.ActorData
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.ActorRole
|
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.BuildConfig
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
|
||||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
|
||||||
import com.lagradost.cloudstream3.NextAiring
|
|
||||||
import com.lagradost.cloudstream3.R
|
|
||||||
import com.lagradost.cloudstream3.Score
|
|
||||||
import com.lagradost.cloudstream3.TvType
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
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.parseJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
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.DataStore.toKotlinObject
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import java.net.URL
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class AniListApi : SyncAPI() {
|
class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
override var name = "AniList"
|
override var name = "AniList"
|
||||||
|
override val key = "6871"
|
||||||
|
override val redirectUrl = "anilistlogin"
|
||||||
override val idPrefix = "anilist"
|
override val idPrefix = "anilist"
|
||||||
|
|
||||||
private val key = BuildConfig.ANILIST_KEY
|
|
||||||
override val redirectUrlIdentifier = "anilistlogin"
|
|
||||||
override var requireLibraryRefresh = true
|
override var requireLibraryRefresh = true
|
||||||
override val hasOAuth2 = true
|
override val supportDeviceAuth = false
|
||||||
override var mainUrl = "https://anilist.co"
|
override var mainUrl = "https://anilist.co"
|
||||||
override val icon = R.drawable.ic_anilist_icon
|
override val icon = R.drawable.ic_anilist_icon
|
||||||
|
override val requiresLogin = false
|
||||||
override val createAccountUrl = "$mainUrl/signup"
|
override val createAccountUrl = "$mainUrl/signup"
|
||||||
override val syncIdName = SyncIdName.Anilist
|
override val syncIdName = SyncIdName.Anilist
|
||||||
|
|
||||||
override fun loginRequest(): AuthLoginPage? =
|
override fun loginInfo(): AuthAPI.LoginInfo? {
|
||||||
AuthLoginPage("https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token")
|
// context.getUser(true)?.
|
||||||
|
getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.let { user ->
|
||||||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
return AuthAPI.LoginInfo(
|
||||||
val sanitizer = splitRedirectUrl(redirectUrl)
|
|
||||||
val token = AuthToken(
|
|
||||||
accessToken = sanitizer["access_token"]
|
|
||||||
?: throw ErrorLoadingException("No access token"),
|
|
||||||
//refreshToken = sanitizer["refresh_token"],
|
|
||||||
accessTokenLifetime = APIHolder.unixTime + sanitizer["expires_in"]!!.toLong(),
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://docs.anilist.co/guide/auth/
|
|
||||||
override suspend fun refreshToken(token: AuthToken): AuthToken? {
|
|
||||||
// AniList access tokens are long-lived. They will remain valid for 1 year from the time they are issued.
|
|
||||||
// Refresh tokens are not currently supported. Once a token expires, you will need to re-authenticate your users.
|
|
||||||
return super.refreshToken(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun 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,
|
profilePicture = user.picture,
|
||||||
|
name = user.name,
|
||||||
|
accountIndex = accountIndex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
override fun urlToId(url: String): String? =
|
override fun logOut() {
|
||||||
url.removePrefix("$mainUrl/anime/").removeSuffix("/")
|
requireLibraryRefresh = true
|
||||||
|
removeAccountKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun authenticate(activity: FragmentActivity?) {
|
||||||
|
val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token"
|
||||||
|
openBrowser(request, activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
private fun getUrlFromId(id: Int): String {
|
||||||
return "$mainUrl/anime/$id"
|
return "$mainUrl/anime/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
val data = searchShows(query) ?: return null
|
val data = searchShows(name) ?: return null
|
||||||
return data.data?.page?.media?.map {
|
return data.data?.page?.media?.map {
|
||||||
SyncAPI.SyncSearchResult(
|
SyncAPI.SyncSearchResult(
|
||||||
it.title.romaji ?: return null,
|
it.title.romaji ?: return null,
|
||||||
|
|
@ -99,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)
|
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
|
||||||
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
||||||
val season = getSeason(internalId).data.media
|
val season = getSeason(internalId).data.media
|
||||||
|
|
@ -109,7 +109,7 @@ class AniListApi : SyncAPI() {
|
||||||
nextAiring = season.nextAiringEpisode?.let {
|
nextAiring = season.nextAiringEpisode?.let {
|
||||||
NextAiring(
|
NextAiring(
|
||||||
it.episode ?: return@let null,
|
it.episode ?: return@let null,
|
||||||
(it.timeUntilAiring ?: return@let null) + APIHolder.unixTime
|
(it.timeUntilAiring ?: return@let null) + unixTime
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
title = season.title?.userPreferred,
|
title = season.title?.userPreferred,
|
||||||
|
|
@ -141,11 +141,11 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
publicScore = Score.from100(season.averageScore),
|
publicScore = season.averageScore?.times(100),
|
||||||
recommendations = season.recommendations?.edges?.mapNotNull { rec ->
|
recommendations = season.recommendations?.edges?.mapNotNull { rec ->
|
||||||
val recMedia = rec.node.mediaRecommendation
|
val recMedia = rec.node.mediaRecommendation
|
||||||
SyncAPI.SyncSearchResult(
|
SyncAPI.SyncSearchResult(
|
||||||
name = recMedia?.title?.userPreferred ?: return@mapNotNull null,
|
name = recMedia.title?.userPreferred ?: return@mapNotNull null,
|
||||||
this.name,
|
this.name,
|
||||||
recMedia.id?.toString() ?: return@mapNotNull null,
|
recMedia.id?.toString() ?: return@mapNotNull null,
|
||||||
getUrlFromId(recMedia.id),
|
getUrlFromId(recMedia.id),
|
||||||
|
|
@ -161,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 internalId = id.toIntOrNull() ?: return null
|
||||||
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
|
val data = getDataAboutId(internalId) ?: return null
|
||||||
|
|
||||||
return SyncAPI.SyncStatus(
|
return SyncAPI.SyncStatus(
|
||||||
score = Score.from100(data.score),
|
score = data.score,
|
||||||
watchedEpisodes = data.progress,
|
watchedEpisodes = data.progress,
|
||||||
status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
|
status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
|
||||||
isFavorite = data.isFavourite,
|
isFavorite = data.isFavourite,
|
||||||
|
|
@ -174,25 +174,24 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateStatus(
|
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||||
auth: AuthData?,
|
|
||||||
id: String,
|
|
||||||
newStatus: AbstractSyncStatus
|
|
||||||
): Boolean {
|
|
||||||
return postDataAboutId(
|
return postDataAboutId(
|
||||||
auth ?: return false,
|
|
||||||
id.toIntOrNull() ?: return false,
|
id.toIntOrNull() ?: return false,
|
||||||
fromIntToAnimeStatus(newStatus.status.internalId),
|
fromIntToAnimeStatus(status.status.internalId),
|
||||||
newStatus.score,
|
status.score,
|
||||||
newStatus.watchedEpisodes
|
status.watchedEpisodes
|
||||||
)
|
).also {
|
||||||
|
requireLibraryRefresh = requireLibraryRefresh || it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MAX_STALE = 60 * 10
|
|
||||||
private val aniListStatusString =
|
private val aniListStatusString =
|
||||||
arrayOf("CURRENT", "COMPLETED", "PAUSED", "DROPPED", "PLANNING", "REPEATING")
|
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"
|
const val ANILIST_CACHED_LIST: String = "anilist_cached_list"
|
||||||
|
|
||||||
private fun fixName(name: String): String {
|
private fun fixName(name: String): String {
|
||||||
|
|
@ -462,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 =
|
val q =
|
||||||
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
|
"""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)
|
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
|
||||||
|
|
@ -472,7 +485,7 @@ class AniListApi : SyncAPI() {
|
||||||
mediaListEntry {
|
mediaListEntry {
|
||||||
progress
|
progress
|
||||||
status
|
status
|
||||||
score (format: POINT_100)
|
score (format: POINT_10)
|
||||||
}
|
}
|
||||||
title {
|
title {
|
||||||
english
|
english
|
||||||
|
|
@ -481,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 d = parseJson<GetDataRoot>(data ?: return null)
|
||||||
|
|
||||||
val main = d.data?.media
|
val main = d.data?.media
|
||||||
|
|
@ -509,11 +522,21 @@ class AniListApi : SyncAPI() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? {
|
private fun getAuth(): String? {
|
||||||
return app.post(
|
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/",
|
"https://graphql.anilist.co/",
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
"Authorization" to "Bearer ${token.accessToken ?: return null}",
|
"Authorization" to "Bearer " + (getAuth()
|
||||||
|
?: return@suspendSafeApiCall null),
|
||||||
if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
|
if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
|
||||||
),
|
),
|
||||||
cacheTime = 0,
|
cacheTime = 0,
|
||||||
|
|
@ -525,8 +548,11 @@ class AniListApi : SyncAPI() {
|
||||||
), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
|
), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
|
||||||
timeout = 5 // REASONABLE TIMEOUT
|
timeout = 5 // REASONABLE TIMEOUT
|
||||||
).text.replace("\\/", "/")
|
).text.replace("\\/", "/")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
data class MediaRecommendation(
|
data class MediaRecommendation(
|
||||||
@JsonProperty("id") val id: Int,
|
@JsonProperty("id") val id: Int,
|
||||||
|
|
@ -598,7 +624,7 @@ class AniListApi : SyncAPI() {
|
||||||
this.media.id.toString(),
|
this.media.id.toString(),
|
||||||
this.progress,
|
this.progress,
|
||||||
this.media.episodes,
|
this.media.episodes,
|
||||||
Score.from100(this.score),
|
this.score,
|
||||||
this.updatedAt.toLong(),
|
this.updatedAt.toLong(),
|
||||||
"AniList",
|
"AniList",
|
||||||
TvType.Anime,
|
TvType.Anime,
|
||||||
|
|
@ -626,23 +652,27 @@ class AniListApi : SyncAPI() {
|
||||||
@JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
|
@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) {
|
return if (requireLibraryRefresh) {
|
||||||
val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray()
|
val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
|
||||||
if (list != null) {
|
if (list != null) {
|
||||||
setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list)
|
setKey(ANILIST_CACHED_LIST, list)
|
||||||
}
|
}
|
||||||
list
|
list
|
||||||
} else {
|
} else {
|
||||||
getKey<Array<Lists>>(
|
getAniListListCached()
|
||||||
ANILIST_CACHED_LIST,
|
|
||||||
auth.user.id.toString()
|
|
||||||
) as? Array<Lists>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
|
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
|
||||||
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
|
val list = getAniListAnimeListSmart()?.groupBy {
|
||||||
convertAniListStringToStatus(it.status ?: "").stringRes
|
convertAniListStringToStatus(it.status ?: "").stringRes
|
||||||
}?.mapValues { group ->
|
}?.mapValues { group ->
|
||||||
group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten()
|
group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten()
|
||||||
|
|
@ -669,8 +699,10 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? {
|
private suspend fun getFullAniListList(): FullAnilistList? {
|
||||||
val userID = auth.user.id
|
/** WARNING ASSUMES ONE USER! **/
|
||||||
|
|
||||||
|
val userID = getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.id ?: return null
|
||||||
val mediaType = "ANIME"
|
val mediaType = "ANIME"
|
||||||
|
|
||||||
val query = """
|
val query = """
|
||||||
|
|
@ -713,11 +745,11 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
val text = postApi(auth.token, query)
|
val text = postApi(query)
|
||||||
return text?.toKotlinObject()
|
return text?.toKotlinObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun toggleLike(auth: AuthData, id: Int): Boolean {
|
suspend fun toggleLike(id: Int): Boolean {
|
||||||
val q = """mutation (${'$'}animeId: Int = $id) {
|
val q = """mutation (${'$'}animeId: Int = $id) {
|
||||||
ToggleFavourite (animeId: ${'$'}animeId) {
|
ToggleFavourite (animeId: ${'$'}animeId) {
|
||||||
anime {
|
anime {
|
||||||
|
|
@ -730,7 +762,7 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
val data = postApi(auth.token, q)
|
val data = postApi(q)
|
||||||
return data != ""
|
return data != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -740,17 +772,15 @@ class AniListApi : SyncAPI() {
|
||||||
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
||||||
|
|
||||||
private suspend fun postDataAboutId(
|
private suspend fun postDataAboutId(
|
||||||
auth: AuthData,
|
|
||||||
id: Int,
|
id: Int,
|
||||||
type: AniListStatusType,
|
type: AniListStatusType,
|
||||||
score: Score?,
|
score: Int?,
|
||||||
progress: Int?
|
progress: Int?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val userID = auth.user.id
|
|
||||||
|
|
||||||
val q =
|
val q =
|
||||||
// Delete item if status type is None
|
// Delete item if status type is None
|
||||||
if (type == AniListStatusType.None) {
|
if (type == AniListStatusType.None) {
|
||||||
|
val userID = getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.id ?: return false
|
||||||
// Get list ID for deletion
|
// Get list ID for deletion
|
||||||
val idQuery = """
|
val idQuery = """
|
||||||
query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) {
|
query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) {
|
||||||
|
|
@ -759,7 +789,7 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
val response = postApi(auth.token, idQuery)
|
val response = postApi(idQuery)
|
||||||
val listId =
|
val listId =
|
||||||
tryParseJson<MediaListItemRoot>(response)?.data?.mediaList?.id ?: return false
|
tryParseJson<MediaListItemRoot>(response)?.data?.mediaList?.id ?: return false
|
||||||
"""
|
"""
|
||||||
|
|
@ -775,7 +805,7 @@ class AniListApi : SyncAPI() {
|
||||||
0,
|
0,
|
||||||
type.value
|
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) {
|
SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
|
||||||
id
|
id
|
||||||
status
|
status
|
||||||
|
|
@ -785,11 +815,11 @@ class AniListApi : SyncAPI() {
|
||||||
}"""
|
}"""
|
||||||
}
|
}
|
||||||
|
|
||||||
val data = postApi(auth.token, q)
|
val data = postApi(q)
|
||||||
return data != ""
|
return data != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getUser(token: AuthToken): AniListUser? {
|
private suspend fun getUser(setSettings: Boolean = true): AniListUser? {
|
||||||
val q = """
|
val q = """
|
||||||
{
|
{
|
||||||
Viewer {
|
Viewer {
|
||||||
|
|
@ -807,15 +837,23 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
val data = postApi(token, q)
|
val data = postApi(q)
|
||||||
if (data.isNullOrBlank()) return null
|
if (data.isNullOrBlank()) return null
|
||||||
val userData = parseJson<AniListRoot>(data)
|
val userData = parseJson<AniListRoot>(data)
|
||||||
val u = userData.data?.viewer ?: return null
|
val u = userData.data?.viewer
|
||||||
val user = AniListUser(
|
val user = AniListUser(
|
||||||
u.id,
|
u?.id,
|
||||||
u.name,
|
u?.name,
|
||||||
u.avatar?.large,
|
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
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -879,8 +917,7 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Recommendation(
|
data class Recommendation(
|
||||||
val id: Long,
|
@JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia,
|
||||||
@JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia?,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CharacterName(
|
data class CharacterName(
|
||||||
|
|
@ -1010,8 +1047,8 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AniListViewer(
|
data class AniListViewer(
|
||||||
@JsonProperty("id") val id: Int,
|
@JsonProperty("id") val id: Int?,
|
||||||
@JsonProperty("name") val name: String,
|
@JsonProperty("name") val name: String?,
|
||||||
@JsonProperty("avatar") val avatar: AniListAvatar?,
|
@JsonProperty("avatar") val avatar: AniListAvatar?,
|
||||||
@JsonProperty("favourites") val favourites: AniListFavourites?,
|
@JsonProperty("favourites") val favourites: AniListFavourites?,
|
||||||
)
|
)
|
||||||
|
|
@ -1025,8 +1062,8 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AniListUser(
|
data class AniListUser(
|
||||||
@JsonProperty("id") val id: Int,
|
@JsonProperty("id") val id: Int?,
|
||||||
@JsonProperty("name") val name: String,
|
@JsonProperty("name") val name: String?,
|
||||||
@JsonProperty("picture") val picture: String?,
|
@JsonProperty("picture") val picture: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,677 +1,8 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
|
||||||
import com.lagradost.cloudstream3.R
|
|
||||||
import com.lagradost.cloudstream3.Score
|
|
||||||
import com.lagradost.cloudstream3.ShowStatus
|
|
||||||
import com.lagradost.cloudstream3.TvType
|
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
const val KITSU_MAX_SEARCH_LIMIT = 20
|
|
||||||
|
|
||||||
class KitsuApi: SyncAPI() {
|
|
||||||
override var name = "Kitsu"
|
|
||||||
override val idPrefix = "kitsu"
|
|
||||||
|
|
||||||
private val apiUrl = "https://kitsu.io/api/edge"
|
|
||||||
private val fallbackApiUrl = "https://kitsu.app/api/edge"
|
|
||||||
private val oauthUrl = "https://kitsu.io/api/oauth"
|
|
||||||
private val fallbackOauthUrl = "https://kitsu.app/api/oauth"
|
|
||||||
override val hasInApp = true
|
|
||||||
override val mainUrl = "https://kitsu.app"
|
|
||||||
override val icon = R.drawable.kitsu_icon
|
|
||||||
override val syncIdName = SyncIdName.Kitsu
|
|
||||||
override val createAccountUrl = mainUrl
|
|
||||||
|
|
||||||
override val supportedWatchTypes = setOf(
|
|
||||||
SyncWatchType.WATCHING,
|
|
||||||
SyncWatchType.COMPLETED,
|
|
||||||
SyncWatchType.PLANTOWATCH,
|
|
||||||
SyncWatchType.DROPPED,
|
|
||||||
SyncWatchType.ONHOLD,
|
|
||||||
SyncWatchType.NONE
|
|
||||||
)
|
|
||||||
|
|
||||||
override val inAppLoginRequirement = AuthLoginRequirement(
|
|
||||||
password = true,
|
|
||||||
email = true
|
|
||||||
)
|
|
||||||
|
|
||||||
private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor {
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request: Request = chain.request()
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
val response = chain.proceed(request);
|
|
||||||
|
|
||||||
if (response.isSuccessful) return response
|
|
||||||
|
|
||||||
response.close()
|
|
||||||
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
|
|
||||||
val fallbackRequest: Request = request.newBuilder()
|
|
||||||
.url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return chain.proceed(fallbackRequest)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl)
|
|
||||||
private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl)
|
|
||||||
|
|
||||||
override suspend fun login(form: AuthLoginResponse): AuthToken? {
|
|
||||||
val username = form.email ?: return null
|
|
||||||
val password = form.password ?: return null
|
|
||||||
|
|
||||||
val grantType = "password"
|
|
||||||
|
|
||||||
val token = app.post(
|
|
||||||
"$oauthUrl/token",
|
|
||||||
data = mapOf(
|
|
||||||
"grant_type" to grantType,
|
|
||||||
"username" to username,
|
|
||||||
"password" to password
|
|
||||||
),
|
|
||||||
interceptor = oauthFallbackInterceptor
|
|
||||||
).parsed<ResponseToken>()
|
|
||||||
|
|
||||||
return AuthToken(
|
|
||||||
accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(),
|
|
||||||
refreshToken = token.refreshToken,
|
|
||||||
accessToken = token.accessToken,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun refreshToken(token: AuthToken): AuthToken {
|
|
||||||
val res = app.post(
|
|
||||||
"$oauthUrl/token",
|
|
||||||
data = mapOf(
|
|
||||||
"grant_type" to "refresh_token",
|
|
||||||
"refresh_token" to token.refreshToken!!
|
|
||||||
),
|
|
||||||
interceptor = oauthFallbackInterceptor
|
|
||||||
).parsed<ResponseToken>()
|
|
||||||
|
|
||||||
return AuthToken(
|
|
||||||
accessToken = res.accessToken,
|
|
||||||
refreshToken = res.refreshToken,
|
|
||||||
accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun user(token: AuthToken?): AuthUser? {
|
|
||||||
val user = app.get(
|
|
||||||
"$apiUrl/users?filter[self]=true",
|
|
||||||
headers = mapOf(
|
|
||||||
"Authorization" to "Bearer ${token?.accessToken ?: return null}"
|
|
||||||
), cacheTime = 0,
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
).parsed<KitsuResponse>()
|
|
||||||
|
|
||||||
if (user.data.isEmpty()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return AuthUser(
|
|
||||||
id = user.data[0].id.toInt(),
|
|
||||||
name = user.data[0].attributes.name,
|
|
||||||
profilePicture = user.data[0].attributes.avatar?.original
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? {
|
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
|
||||||
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount")
|
|
||||||
val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
|
|
||||||
|
|
||||||
val res = app.get(
|
|
||||||
url, headers = mapOf(
|
|
||||||
"Authorization" to "Bearer $auth",
|
|
||||||
), cacheTime = 0,
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
).parsed<KitsuResponse>()
|
|
||||||
|
|
||||||
return res.data.map {
|
|
||||||
val attributes = it.attributes
|
|
||||||
|
|
||||||
val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title"
|
|
||||||
|
|
||||||
SyncSearchResult(
|
|
||||||
title,
|
|
||||||
this.name,
|
|
||||||
it.id,
|
|
||||||
"$mainUrl/anime/${it.id}/",
|
|
||||||
attributes.posterImage?.large ?: attributes.posterImage?.medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun load(auth : AuthData?, id: String): SyncResult? {
|
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
|
||||||
if (id.toIntOrNull() == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt
|
||||||
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
|
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
import com.lagradost.cloudstream3.ui.WatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||||
|
|
@ -14,19 +16,56 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
|
||||||
|
|
||||||
class LocalList : SyncAPI() {
|
class LocalList : SyncAPI {
|
||||||
override val name = "Local"
|
override val name = "Local"
|
||||||
override val idPrefix = "local"
|
|
||||||
|
|
||||||
override val icon: Int = R.drawable.ic_baseline_storage_24
|
override val icon: Int = R.drawable.ic_baseline_storage_24
|
||||||
override val requiresLogin = false
|
override val requiresLogin = false
|
||||||
override val createAccountUrl = null
|
override val supportDeviceAuth = false
|
||||||
|
override val createAccountUrl: Nothing? = null
|
||||||
|
override val idPrefix = "local"
|
||||||
override var requireLibraryRefresh = true
|
override var requireLibraryRefresh = true
|
||||||
override val syncIdName = SyncIdName.LocalList
|
|
||||||
|
|
||||||
override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? {
|
override fun loginInfo(): AuthAPI.LoginInfo {
|
||||||
|
return AuthAPI.LoginInfo(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logOut() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override val key: String = ""
|
||||||
|
override val redirectUrl = ""
|
||||||
|
override suspend fun handleRedirect(url: String): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun authenticate(activity: FragmentActivity?) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override val mainUrl = ""
|
||||||
|
override val syncIdName = SyncIdName.LocalList
|
||||||
|
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
|
||||||
val watchStatusIds = ioWork {
|
val watchStatusIds = ioWork {
|
||||||
getAllWatchStateIds()?.map { id ->
|
getAllWatchStateIds()?.map { id ->
|
||||||
Pair(id, getResultWatchState(id))
|
Pair(id, getResultWatchState(id))
|
||||||
|
|
@ -63,8 +102,7 @@ class LocalList : SyncAPI() {
|
||||||
val result = if (isTrueTv) {
|
val result = if (isTrueTv) {
|
||||||
baseMap + watchStatusMap + favoritesMap
|
baseMap + watchStatusMap + favoritesMap
|
||||||
} else {
|
} else {
|
||||||
val subscriptionsMap =
|
val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
|
||||||
mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
|
|
||||||
it.toLibraryItem()
|
it.toLibraryItem()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -74,8 +112,8 @@ class LocalList : SyncAPI() {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
return LibraryMetadata(
|
return SyncAPI.LibraryMetadata(
|
||||||
list.map { LibraryList(txt(it.key), it.value) },
|
list.map { SyncAPI.LibraryList(txt(it.key), it.value) },
|
||||||
setOf(
|
setOf(
|
||||||
ListSorting.AlphabeticalA,
|
ListSorting.AlphabeticalA,
|
||||||
ListSorting.AlphabeticalZ,
|
ListSorting.AlphabeticalZ,
|
||||||
|
|
@ -89,4 +127,8 @@ class LocalList : SyncAPI() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getIdFromUrl(url: String): String {
|
||||||
|
return url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,114 +1,87 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.BuildConfig
|
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.Score
|
|
||||||
import com.lagradost.cloudstream3.ShowStatus
|
import com.lagradost.cloudstream3.ShowStatus
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
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.parseJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
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.text.SimpleDateFormat
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
/** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
|
/** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
|
||||||
const val MAL_MAX_SEARCH_LIMIT = 25
|
const val MAL_MAX_SEARCH_LIMIT = 25
|
||||||
|
|
||||||
class MALApi : SyncAPI() {
|
class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
override var name = "MAL"
|
override var name = "MAL"
|
||||||
|
override val key = "1714d6f2f4f7cc19644384f8c4629910"
|
||||||
|
override val redirectUrl = "mallogin"
|
||||||
override val idPrefix = "mal"
|
override val idPrefix = "mal"
|
||||||
|
override var mainUrl = "https://myanimelist.net"
|
||||||
private val key = BuildConfig.MAL_KEY
|
|
||||||
private val apiUrl = "https://api.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 icon = R.drawable.mal_logo
|
||||||
|
override val requiresLogin = false
|
||||||
|
override val supportDeviceAuth = false
|
||||||
override val syncIdName = SyncIdName.MyAnimeList
|
override val syncIdName = SyncIdName.MyAnimeList
|
||||||
|
override var requireLibraryRefresh = true
|
||||||
override val createAccountUrl = "$mainUrl/register.php"
|
override val createAccountUrl = "$mainUrl/register.php"
|
||||||
|
|
||||||
override val supportedWatchTypes = setOf(
|
override fun logOut() {
|
||||||
SyncWatchType.WATCHING,
|
requireLibraryRefresh = true
|
||||||
SyncWatchType.COMPLETED,
|
removeAccountKeys()
|
||||||
SyncWatchType.PLANTOWATCH,
|
}
|
||||||
SyncWatchType.DROPPED,
|
|
||||||
SyncWatchType.ONHOLD,
|
override fun loginInfo(): AuthAPI.LoginInfo? {
|
||||||
SyncWatchType.NONE
|
getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
|
||||||
|
return AuthAPI.LoginInfo(
|
||||||
|
profilePicture = user.picture,
|
||||||
|
name = user.name,
|
||||||
|
accountIndex = accountIndex
|
||||||
)
|
)
|
||||||
|
}
|
||||||
data class PayLoad(
|
|
||||||
val requestId: Int,
|
|
||||||
val codeVerifier: String
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
|
||||||
val payloadData = parseJson<PayLoad>(payload!!)
|
|
||||||
val sanitizer = splitRedirectUrl(redirectUrl)
|
|
||||||
val state = sanitizer["state"]!!
|
|
||||||
|
|
||||||
if (state != "RequestID${payloadData.requestId}") {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentCode = sanitizer["code"]!!
|
private fun getAuth(): String? {
|
||||||
|
return getKey(
|
||||||
val token = app.post(
|
accountId,
|
||||||
"$mainUrl/v1/oauth2/token",
|
MAL_TOKEN_KEY
|
||||||
data = mapOf(
|
|
||||||
"client_id" to key,
|
|
||||||
"code" to currentCode,
|
|
||||||
"code_verifier" to payloadData.codeVerifier,
|
|
||||||
"grant_type" to "authorization_code"
|
|
||||||
)
|
|
||||||
).parsed<ResponseToken>()
|
|
||||||
return AuthToken(
|
|
||||||
accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(),
|
|
||||||
refreshToken = token.refreshToken,
|
|
||||||
accessToken = token.accessToken
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun user(token: AuthToken?): AuthUser? {
|
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult> {
|
||||||
val user = app.get(
|
val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
|
||||||
"$apiUrl/v2/users/@me",
|
val auth = getAuth() ?: return emptyList()
|
||||||
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"
|
|
||||||
val res = app.get(
|
val res = app.get(
|
||||||
url, headers = mapOf(
|
url, headers = mapOf(
|
||||||
"Authorization" to "Bearer $auth",
|
"Authorization" to "Bearer $auth",
|
||||||
), cacheTime = 0
|
), cacheTime = 0
|
||||||
).parsed<MalSearch>()
|
).text
|
||||||
return res.data.map {
|
return parseJson<MalSearch>(res).data.map {
|
||||||
val node = it.node
|
val node = it.node
|
||||||
SyncAPI.SyncSearchResult(
|
SyncAPI.SyncSearchResult(
|
||||||
node.title,
|
node.title,
|
||||||
|
|
@ -120,21 +93,19 @@ class MALApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun urlToId(url: String): String? =
|
override fun getIdFromUrl(url: String): String {
|
||||||
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun updateStatus(
|
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||||
auth: AuthData?,
|
|
||||||
id: String,
|
|
||||||
newStatus: SyncAPI.AbstractSyncStatus
|
|
||||||
): Boolean {
|
|
||||||
return setScoreRequest(
|
return setScoreRequest(
|
||||||
auth?.token ?: return false,
|
|
||||||
id.toIntOrNull() ?: return false,
|
id.toIntOrNull() ?: return false,
|
||||||
fromIntToAnimeStatus(newStatus.status),
|
fromIntToAnimeStatus(status.status.internalId),
|
||||||
newStatus.score?.toInt(10),
|
status.score,
|
||||||
newStatus.watchedEpisodes
|
status.watchedEpisodes
|
||||||
)
|
).also {
|
||||||
|
requireLibraryRefresh = requireLibraryRefresh || it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MalAnime(
|
data class MalAnime(
|
||||||
|
|
@ -227,14 +198,14 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
|
||||||
val internalId = id.toIntOrNull() ?: return null
|
val internalId = id.toIntOrNull() ?: return null
|
||||||
val url =
|
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"
|
"$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(
|
val res = app.get(
|
||||||
url, headers = mapOf(
|
url, headers = if (auth == null) emptyMap() else mapOf(
|
||||||
"Authorization" to "Bearer $auth"
|
"Authorization" to "Bearer $auth"
|
||||||
)
|
)
|
||||||
).text
|
).text
|
||||||
|
|
@ -243,7 +214,7 @@ class MALApi : SyncAPI() {
|
||||||
id = internalId.toString(),
|
id = internalId.toString(),
|
||||||
totalEpisodes = malAnime.numEpisodes,
|
totalEpisodes = malAnime.numEpisodes,
|
||||||
title = malAnime.title,
|
title = malAnime.title,
|
||||||
publicScore = Score.from10(malAnime.mean),
|
publicScore = malAnime.mean?.toFloat()?.times(1000)?.toInt(),
|
||||||
duration = malAnime.averageEpisodeDuration,
|
duration = malAnime.averageEpisodeDuration,
|
||||||
synopsis = malAnime.synopsis,
|
synopsis = malAnime.synopsis,
|
||||||
airStatus = when (malAnime.status) {
|
airStatus = when (malAnime.status) {
|
||||||
|
|
@ -273,20 +244,13 @@ class MALApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
val internalId = id.toIntOrNull() ?: 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
|
|
||||||
|
|
||||||
|
val data =
|
||||||
|
getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status")
|
||||||
return SyncAPI.SyncStatus(
|
return SyncAPI.SyncStatus(
|
||||||
score = Score.from10(data?.score),
|
score = data?.score,
|
||||||
status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
|
status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
|
||||||
isFavorite = null,
|
isFavorite = null,
|
||||||
watchedEpisodes = data?.numEpisodesWatched,
|
watchedEpisodes = data?.numEpisodesWatched,
|
||||||
|
|
@ -297,17 +261,14 @@ class MALApi : SyncAPI() {
|
||||||
private val malStatusAsString =
|
private val malStatusAsString =
|
||||||
arrayOf("watching", "completed", "on_hold", "dropped", "plan_to_watch")
|
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_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 {
|
fun convertToStatus(string: String): MalStatusType {
|
||||||
return when (string) {
|
return fromIntToAnimeStatus(malStatusAsString.indexOf(string))
|
||||||
"watching" -> MalStatusType.Watching
|
|
||||||
"completed" -> MalStatusType.Completed
|
|
||||||
"on_hold" -> MalStatusType.OnHold
|
|
||||||
"dropped" -> MalStatusType.Dropped
|
|
||||||
"plan_to_watch" -> MalStatusType.PlanToWatch
|
|
||||||
else -> MalStatusType.None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) {
|
enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) {
|
||||||
|
|
@ -319,15 +280,16 @@ class MALApi : SyncAPI() {
|
||||||
None(-1, R.string.type_none)
|
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) {
|
return when (inp) {
|
||||||
SyncWatchType.NONE -> MalStatusType.None
|
-1 -> MalStatusType.None
|
||||||
SyncWatchType.WATCHING -> MalStatusType.Watching
|
0 -> MalStatusType.Watching
|
||||||
SyncWatchType.COMPLETED -> MalStatusType.Completed
|
1 -> MalStatusType.Completed
|
||||||
SyncWatchType.ONHOLD -> MalStatusType.OnHold
|
2 -> MalStatusType.OnHold
|
||||||
SyncWatchType.DROPPED -> MalStatusType.Dropped
|
3 -> MalStatusType.Dropped
|
||||||
SyncWatchType.PLANTOWATCH -> MalStatusType.PlanToWatch
|
4 -> MalStatusType.PlanToWatch
|
||||||
SyncWatchType.REWATCHING -> MalStatusType.Watching
|
5 -> MalStatusType.Watching
|
||||||
|
else -> MalStatusType.None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,38 +304,85 @@ class MALApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loginRequest(): AuthLoginPage? {
|
override suspend fun handleRedirect(url: String): Boolean {
|
||||||
val codeVerifier = generateCodeVerifier()
|
val sanitizer =
|
||||||
val requestId = ++requestIdCounter
|
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 codeChallenge = codeVerifier
|
||||||
val request =
|
val request =
|
||||||
"$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId"
|
"$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId"
|
||||||
|
openBrowser(request, activity)
|
||||||
return AuthLoginPage(
|
|
||||||
url = request,
|
|
||||||
payload = PayLoad(requestId, codeVerifier).toJson()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun refreshToken(token: AuthToken): AuthToken? {
|
private var requestId = 0
|
||||||
|
private var codeVerifier = ""
|
||||||
|
|
||||||
|
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 suspend fun refreshToken() {
|
||||||
|
try {
|
||||||
val res = app.post(
|
val res = app.post(
|
||||||
"$mainUrl/v1/oauth2/token",
|
"$mainUrl/v1/oauth2/token",
|
||||||
data = mapOf(
|
data = mapOf(
|
||||||
"client_id" to key,
|
"client_id" to key,
|
||||||
"grant_type" to "refresh_token",
|
"grant_type" to "refresh_token",
|
||||||
"refresh_token" to token.refreshToken!!
|
"refresh_token" to getKey(
|
||||||
)
|
accountId,
|
||||||
).parsed<ResponseToken>()
|
MAL_REFRESH_TOKEN_KEY
|
||||||
|
)!!
|
||||||
return AuthToken(
|
|
||||||
accessToken = res.accessToken,
|
|
||||||
refreshToken = res.refreshToken,
|
|
||||||
accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong()
|
|
||||||
)
|
)
|
||||||
|
).text
|
||||||
|
storeToken(res)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var requestIdCounter = 0
|
|
||||||
|
|
||||||
|
|
||||||
private val allTitles = hashMapOf<Int, MalTitleHolder>()
|
private val allTitles = hashMapOf<Int, MalTitleHolder>()
|
||||||
|
|
||||||
|
|
@ -432,7 +441,7 @@ class MALApi : SyncAPI() {
|
||||||
this.node.id.toString(),
|
this.node.id.toString(),
|
||||||
this.listStatus?.numEpisodesWatched,
|
this.listStatus?.numEpisodesWatched,
|
||||||
this.node.numEpisodes,
|
this.node.numEpisodes,
|
||||||
Score.from10(this.listStatus?.score),
|
this.listStatus?.score?.times(10),
|
||||||
parseDateLong(this.listStatus?.updatedAt),
|
parseDateLong(this.listStatus?.updatedAt),
|
||||||
"MAL",
|
"MAL",
|
||||||
TvType.Anime,
|
TvType.Anime,
|
||||||
|
|
@ -440,16 +449,12 @@ class MALApi : SyncAPI() {
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
plot = this.node.synopsis,
|
plot = this.node.synopsis,
|
||||||
releaseDate = if (this.node.startDate == null) null else try {
|
releaseDate = if (this.node.startDate == null) null else try {Date.from(
|
||||||
Date.from(
|
|
||||||
Instant.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")
|
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)
|
.parse(this.node.startDate)
|
||||||
)
|
)
|
||||||
)
|
)} catch (_: RuntimeException) {null}
|
||||||
} catch (_: RuntimeException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -479,8 +484,23 @@ class MALApi : SyncAPI() {
|
||||||
@JsonProperty("start_time") val startTime: String?
|
@JsonProperty("start_time") val startTime: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun library(auth: AuthData?): LibraryMetadata? {
|
private fun getMalAnimeListCached(): Array<Data>? {
|
||||||
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
|
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
|
convertToStatus(it.listStatus?.status ?: "").stringRes
|
||||||
}?.mapValues { group ->
|
}?.mapValues { group ->
|
||||||
group.value.map { it.toLibraryItem() }
|
group.value.map { it.toLibraryItem() }
|
||||||
|
|
@ -507,22 +527,13 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getMalAnimeListSmart(auth: AuthData): Array<Data>? {
|
private suspend fun getMalAnimeList(): Array<Data> {
|
||||||
return if (requireLibraryRefresh) {
|
checkMalToken()
|
||||||
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> {
|
|
||||||
var offset = 0
|
var offset = 0
|
||||||
val fullList = mutableListOf<Data>()
|
val fullList = mutableListOf<Data>()
|
||||||
val offsetRegex = Regex("""offset=(\d+)""")
|
val offsetRegex = Regex("""offset=(\d+)""")
|
||||||
while (true) {
|
while (true) {
|
||||||
val data: MalList = getMalAnimeListSlice(token, offset) ?: break
|
val data: MalList = getMalAnimeListSlice(offset) ?: break
|
||||||
fullList.addAll(data.data)
|
fullList.addAll(data.data)
|
||||||
offset =
|
offset =
|
||||||
data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() }
|
data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() }
|
||||||
|
|
@ -531,29 +542,128 @@ class MALApi : SyncAPI() {
|
||||||
return fullList.toTypedArray()
|
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 user = "@me"
|
||||||
|
val auth = getAuth() ?: return null
|
||||||
// Very lackluster docs
|
// Very lackluster docs
|
||||||
// https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get
|
// https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get
|
||||||
val url =
|
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"
|
"$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(
|
val res = app.get(
|
||||||
url, headers = mapOf(
|
url, headers = mapOf(
|
||||||
"Authorization" to "Bearer ${token.accessToken}",
|
"Authorization" to "Bearer $auth",
|
||||||
), cacheTime = 0
|
), cacheTime = 0
|
||||||
).text
|
).text
|
||||||
return res.toKotlinObject()
|
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(
|
private suspend fun setScoreRequest(
|
||||||
token: AuthToken,
|
|
||||||
id: Int,
|
id: Int,
|
||||||
status: MalStatusType? = null,
|
status: MalStatusType? = null,
|
||||||
score: Int? = null,
|
score: Int? = null,
|
||||||
numWatchedEpisodes: Int? = null,
|
numWatchedEpisodes: Int? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val res = setScoreRequest(
|
val res = setScoreRequest(
|
||||||
token,
|
|
||||||
id,
|
id,
|
||||||
if (status == null) null else malStatusAsString[maxOf(0, status.value)],
|
if (status == null) null else malStatusAsString[maxOf(0, status.value)],
|
||||||
score,
|
score,
|
||||||
|
|
@ -576,7 +686,6 @@ class MALApi : SyncAPI() {
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private suspend fun setScoreRequest(
|
private suspend fun setScoreRequest(
|
||||||
token: AuthToken,
|
|
||||||
id: Int,
|
id: Int,
|
||||||
status: String? = null,
|
status: String? = null,
|
||||||
score: Int? = null,
|
score: Int? = null,
|
||||||
|
|
@ -591,7 +700,7 @@ class MALApi : SyncAPI() {
|
||||||
return app.put(
|
return app.put(
|
||||||
"$apiUrl/v2/anime/$id/my_list_status",
|
"$apiUrl/v2/anime/$id/my_list_status",
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
"Authorization" to "Bearer ${token.accessToken}"
|
"Authorization" to "Bearer " + (getAuth() ?: return null)
|
||||||
),
|
),
|
||||||
data = data
|
data = data
|
||||||
).text
|
).text
|
||||||
|
|
|
||||||
|
|
@ -2,110 +2,188 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.app
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils
|
import com.lagradost.cloudstream3.utils.AppUtils
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
import okhttp3.Interceptor
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import okhttp3.Response
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
|
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag
|
|
||||||
|
|
||||||
class OpenSubtitlesApi : SubtitleAPI() {
|
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
|
||||||
override val name = "OpenSubtitles"
|
|
||||||
override val idPrefix = "opensubtitles"
|
override val idPrefix = "opensubtitles"
|
||||||
|
override val name = "OpenSubtitles"
|
||||||
override val icon = R.drawable.open_subtitles_icon
|
override val icon = R.drawable.open_subtitles_icon
|
||||||
override val hasInApp = true
|
override val requiresPassword = true
|
||||||
override val inAppLoginRequirement = AuthLoginRequirement(
|
override val requiresUsername = true
|
||||||
password = true,
|
|
||||||
username = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up"
|
override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up"
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile
|
||||||
const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
|
const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
|
||||||
const val HOST = "https://api.opensubtitles.com/api/v1"
|
const val HOST = "https://api.opensubtitles.com/api/v1"
|
||||||
const val TAG = "OPENSUBS"
|
const val TAG = "OPENSUBS"
|
||||||
const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms
|
const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms
|
||||||
var currentCoolDown: Long = 0L
|
var currentCoolDown: Long = 0L
|
||||||
const val userAgent = "Cloudstream3 v0.2"
|
var currentSession: SubtitleOAuthEntity? = null
|
||||||
val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY)
|
}
|
||||||
|
|
||||||
|
private val headerInterceptor = OpenSubtitleInterceptor()
|
||||||
|
|
||||||
|
/** Automatically adds required api headers */
|
||||||
|
private class OpenSubtitleInterceptor : Interceptor {
|
||||||
|
/** Required user agent! */
|
||||||
|
private val userAgent = "Cloudstream3 v0.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 {
|
private fun canDoRequest(): Boolean {
|
||||||
return unixTimeMS > currentCoolDown
|
return unixTimeMs > currentCoolDown
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun throwIfCantDoRequest() {
|
private fun throwIfCantDoRequest() {
|
||||||
if (!canDoRequest()) {
|
if (!canDoRequest()) {
|
||||||
throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMS) / 1000L}s")
|
throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun throwGotTooManyRequests() {
|
private fun throwGotTooManyRequests() {
|
||||||
currentCoolDown = unixTimeMS + COOLDOWN_DURATION
|
currentCoolDown = unixTimeMs + COOLDOWN_DURATION
|
||||||
throw ErrorLoadingException("Too many requests")
|
throw ErrorLoadingException("Too many requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun refreshToken(token: AuthToken): AuthToken? {
|
private fun getAuthKey(): SubtitleOAuthEntity? {
|
||||||
return login(parseJson<AuthLoginResponse>(token.payload ?: return null))
|
return getKey(accountId, OPEN_SUBTITLES_USER_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun user(token: AuthToken?): AuthUser? {
|
private fun setAuthKey(data: SubtitleOAuthEntity?) {
|
||||||
val user = parseJson<AuthLoginResponse>(token?.payload ?: return null)
|
if (data == null) removeKey(accountId, OPEN_SUBTITLES_USER_KEY)
|
||||||
val username = user.username ?: return null
|
currentSession = data
|
||||||
return AuthUser(
|
setKey(accountId, OPEN_SUBTITLES_USER_KEY, data)
|
||||||
id = username.hashCode(),
|
}
|
||||||
name = username
|
|
||||||
|
override fun loginInfo(): AuthAPI.LoginInfo? {
|
||||||
|
getAuthKey()?.let { user ->
|
||||||
|
return AuthAPI.LoginInfo(
|
||||||
|
profilePicture = null,
|
||||||
|
name = user.user,
|
||||||
|
accountIndex = accountIndex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun login(form: AuthLoginResponse): AuthToken? {
|
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
|
||||||
val username = form.username ?: return null
|
val current = getAuthKey() ?: return null
|
||||||
val password = form.password ?: return null
|
return InAppAuthAPI.LoginData(username = current.user, current.pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Authorize app to connect to API, using username/password.
|
||||||
|
Required to run at startup.
|
||||||
|
Returns OAuth entity with valid access token.
|
||||||
|
*/
|
||||||
|
override suspend fun initialize() {
|
||||||
|
currentSession = getAuthKey() ?: return // just in case the following fails
|
||||||
|
initLogin(currentSession?.user ?: return, currentSession?.pass ?: return)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logOut() {
|
||||||
|
setAuthKey(null)
|
||||||
|
removeAccountKeys()
|
||||||
|
currentSession = getAuthKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun initLogin(username: String, password: String): Boolean {
|
||||||
|
//Log.i(TAG, "DATA = [$username] [$password]")
|
||||||
val response = app.post(
|
val response = app.post(
|
||||||
url = "$HOST/login",
|
url = "$HOST/login",
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
"Content-Type" to "application/json",
|
"Content-Type" to "application/json",
|
||||||
) + headers,
|
),
|
||||||
json = mapOf(
|
data = mapOf(
|
||||||
"username" to username,
|
"username" to username,
|
||||||
"password" to password
|
"password" to password
|
||||||
),
|
),
|
||||||
).parsed<OAuthToken>()
|
interceptor = headerInterceptor
|
||||||
|
|
||||||
return AuthToken(
|
|
||||||
accessToken = response.token
|
|
||||||
?: throw ErrorLoadingException("Invalid password or username"),
|
|
||||||
/// JWT token is valid 24 hours after successfully authentication of user
|
|
||||||
accessTokenLifetime = APIHolder.unixTime + 60 * 60 * 24,
|
|
||||||
payload = form.toJson()
|
|
||||||
)
|
)
|
||||||
|
//Log.i(TAG, "Responsecode = ${response.code}")
|
||||||
|
//Log.i(TAG, "Result => ${response.text}")
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
AppUtils.tryParseJson<OAuthToken>(response.text)?.let { token ->
|
||||||
|
setAuthKey(
|
||||||
|
SubtitleOAuthEntity(
|
||||||
|
user = username,
|
||||||
|
pass = password,
|
||||||
|
accessToken = token.token ?: run {
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
|
||||||
|
val username = data.username ?: throw ErrorLoadingException("Requires Username")
|
||||||
|
val password = data.password ?: throw ErrorLoadingException("Requires Password")
|
||||||
|
switchToNewAccount()
|
||||||
|
try {
|
||||||
|
if (initLogin(username, password)) {
|
||||||
|
registerAccount()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
switchToOldAccount()
|
||||||
|
}
|
||||||
|
switchToOldAccount()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some languages do not use the normal country codes on OpenSubtitles
|
||||||
|
* */
|
||||||
|
private val languageExceptions = mapOf<String, String>(
|
||||||
|
// "pt" to "pt-PT",
|
||||||
|
// "pt" to "pt-BR"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fixLanguage(language: String?): String? {
|
||||||
|
return languageExceptions[language] ?: language
|
||||||
|
}
|
||||||
|
|
||||||
|
// O(n) but good enough, BiMap did not want to work properly
|
||||||
|
private fun fixLanguageReverse(language: String?): String? {
|
||||||
|
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch subtitles using token authenticated on previous method (see authorize).
|
* Fetch subtitles using token authenticated on previous method (see authorize).
|
||||||
* Returns list of Subtitles which user can select to download (see load).
|
* Returns list of Subtitles which user can select to download (see load).
|
||||||
* */
|
* */
|
||||||
override suspend fun search(
|
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||||
auth : AuthData?,
|
|
||||||
query: AbstractSubtitleEntities.SubtitleSearch
|
|
||||||
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
|
||||||
throwIfCantDoRequest()
|
throwIfCantDoRequest()
|
||||||
val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: ""
|
val fixedLang = fixLanguage(query.lang)
|
||||||
|
|
||||||
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
|
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
|
||||||
val queryText = query.query
|
val queryText = query.query
|
||||||
|
|
@ -118,17 +196,17 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
|
|
||||||
val searchQueryUrl = when (imdbId > 0) {
|
val searchQueryUrl = when (imdbId > 0) {
|
||||||
//Use imdb_id to search if its valid
|
//Use imdb_id to search if its valid
|
||||||
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
|
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||||
false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
|
false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||||
}
|
}
|
||||||
|
|
||||||
val req = app.get(
|
val req = app.get(
|
||||||
url = searchQueryUrl,
|
url = searchQueryUrl,
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
Pair("Content-Type", "application/json")
|
Pair("Content-Type", "application/json")
|
||||||
) + headers,
|
),
|
||||||
|
interceptor = headerInterceptor
|
||||||
)
|
)
|
||||||
Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}")
|
|
||||||
Log.i(TAG, "Search Req => ${req.text}")
|
Log.i(TAG, "Search Req => ${req.text}")
|
||||||
if (!req.isSuccessful) {
|
if (!req.isSuccessful) {
|
||||||
if (req.code == 429)
|
if (req.code == 429)
|
||||||
|
|
@ -149,7 +227,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
//Use any valid name/title in hierarchy
|
//Use any valid name/title in hierarchy
|
||||||
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
||||||
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
||||||
val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: ""
|
val lang = fixLanguageReverse(attr.language) ?: ""
|
||||||
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
||||||
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
||||||
val year = featureDetails?.year ?: query.year
|
val year = featureDetails?.year ?: query.year
|
||||||
|
|
@ -163,7 +241,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
AbstractSubtitleEntities.SubtitleEntity(
|
AbstractSubtitleEntities.SubtitleEntity(
|
||||||
idPrefix = this.idPrefix,
|
idPrefix = this.idPrefix,
|
||||||
name = name,
|
name = name,
|
||||||
lang = langTagIETF,
|
lang = lang,
|
||||||
data = resultData,
|
data = resultData,
|
||||||
type = type,
|
type = type,
|
||||||
source = this.name,
|
source = this.name,
|
||||||
|
|
@ -183,12 +261,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
Process data returned from search.
|
Process data returned from search.
|
||||||
Returns string url for the subtitle file.
|
Returns string url for the subtitle file.
|
||||||
*/
|
*/
|
||||||
|
override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? {
|
||||||
override suspend fun load(
|
|
||||||
auth : AuthData?,
|
|
||||||
subtitle: AbstractSubtitleEntities.SubtitleEntity
|
|
||||||
): String? {
|
|
||||||
if(auth == null) return null
|
|
||||||
throwIfCantDoRequest()
|
throwIfCantDoRequest()
|
||||||
|
|
||||||
val req = app.post(
|
val req = app.post(
|
||||||
|
|
@ -196,14 +269,15 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
Pair(
|
Pair(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
|
"Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
|
||||||
),
|
),
|
||||||
Pair("Content-Type", "application/json"),
|
Pair("Content-Type", "application/json"),
|
||||||
Pair("Accept", "*/*")
|
Pair("Accept", "*/*")
|
||||||
) + headers,
|
),
|
||||||
data = mapOf(
|
data = mapOf(
|
||||||
Pair("file_id", subtitle.data)
|
Pair("file_id", data.data)
|
||||||
)
|
),
|
||||||
|
interceptor = headerInterceptor
|
||||||
)
|
)
|
||||||
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
|
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
|
||||||
//Log.i(TAG, "Request headers => ${req.headers}")
|
//Log.i(TAG, "Request headers => ${req.headers}")
|
||||||
|
|
@ -220,6 +294,13 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class SubtitleOAuthEntity(
|
||||||
|
var user: String,
|
||||||
|
var pass: String,
|
||||||
|
var accessToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
data class OAuthToken(
|
data class OAuthToken(
|
||||||
@JsonProperty("token") var token: String? = null,
|
@JsonProperty("token") var token: String? = null,
|
||||||
@JsonProperty("status") var status: Int? = null
|
@JsonProperty("status") var status: Int? = null
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,38 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
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.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.LoadResponse.Companion.readIdFromString
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.Score
|
|
||||||
import com.lagradost.cloudstream3.SimklSyncServices
|
import com.lagradost.cloudstream3.SimklSyncServices
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
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.debugPrint
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthPinData
|
import com.lagradost.cloudstream3.syncproviders.OAuth2API
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
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.toJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
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.math.BigInteger
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
|
@ -44,22 +45,25 @@ import kotlin.time.Duration
|
||||||
import kotlin.time.DurationUnit
|
import kotlin.time.DurationUnit
|
||||||
import kotlin.time.toDuration
|
import kotlin.time.toDuration
|
||||||
|
|
||||||
class SimklApi : SyncAPI() {
|
class SimklApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
override var name = "Simkl"
|
override var name = "Simkl"
|
||||||
|
override val key = "simkl-key"
|
||||||
|
override val redirectUrl = "simkl"
|
||||||
|
override val supportDeviceAuth = true
|
||||||
override val idPrefix = "simkl"
|
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 requireLibraryRefresh = true
|
||||||
override var mainUrl = "https://api.simkl.com"
|
override var mainUrl = "https://api.simkl.com"
|
||||||
override val icon = R.drawable.simkl_logo
|
override val icon = R.drawable.simkl_logo
|
||||||
|
override val requiresLogin = false
|
||||||
override val createAccountUrl = "$mainUrl/signup"
|
override val createAccountUrl = "$mainUrl/signup"
|
||||||
override val syncIdName = SyncIdName.Simkl
|
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 */
|
/** 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
|
* This is required to override the reported last activity as simkl activites
|
||||||
|
|
@ -78,15 +82,15 @@ class SimklApi : SyncAPI() {
|
||||||
private class SimklCacheWrapper<T>(
|
private class SimklCacheWrapper<T>(
|
||||||
@JsonProperty("obj") val obj: T?,
|
@JsonProperty("obj") val obj: T?,
|
||||||
@JsonProperty("validUntil") val validUntil: Long,
|
@JsonProperty("validUntil") val validUntil: Long,
|
||||||
@JsonProperty("cacheTime") val cacheTime: Long = APIHolder.unixTime,
|
@JsonProperty("cacheTime") val cacheTime: Long = unixTime,
|
||||||
) {
|
) {
|
||||||
/** Returns true if cache is newer than cacheDays */
|
/** Returns true if cache is newer than cacheDays */
|
||||||
fun isFresh(): Boolean {
|
fun isFresh(): Boolean {
|
||||||
return validUntil > APIHolder.unixTime
|
return validUntil > unixTime
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remainingTime(): Duration {
|
fun remainingTime(): Duration {
|
||||||
val unixTime = APIHolder.unixTime
|
val unixTime = unixTime
|
||||||
return if (validUntil > unixTime) {
|
return if (validUntil > unixTime) {
|
||||||
(validUntil - unixTime).toDuration(DurationUnit.SECONDS)
|
(validUntil - unixTime).toDuration(DurationUnit.SECONDS)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -97,7 +101,7 @@ class SimklApi : SyncAPI() {
|
||||||
|
|
||||||
fun cleanOldCache() {
|
fun cleanOldCache() {
|
||||||
getKeys(SIMKL_CACHE_KEY)?.forEach {
|
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) {
|
if (isOld) {
|
||||||
removeKey(it)
|
removeKey(it)
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +114,7 @@ class SimklApi : SyncAPI() {
|
||||||
SIMKL_CACHE_KEY,
|
SIMKL_CACHE_KEY,
|
||||||
path,
|
path,
|
||||||
// Storing as plain sting is required to make generics work.
|
// Storing as plain sting is required to make generics work.
|
||||||
SimklCacheWrapper(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson()
|
SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,8 +122,13 @@ class SimklApi : SyncAPI() {
|
||||||
* Gets cached object, if object is not fresh returns null and removes it from cache
|
* Gets cached object, if object is not fresh returns null and removes it from cache
|
||||||
*/
|
*/
|
||||||
inline fun <reified T : Any> getKey(path: String): T? {
|
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 {
|
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
|
||||||
tryParseJson<SimklCacheWrapper<T>>(it)
|
mapper.readValue<SimklCacheWrapper<T>>(it, type)
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (cache?.isFresh() == true) {
|
return if (cache?.isFresh() == true) {
|
||||||
|
|
@ -139,6 +148,10 @@ class SimklApi : SyncAPI() {
|
||||||
companion object {
|
companion object {
|
||||||
private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID
|
private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID
|
||||||
private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET
|
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: String = "simkl_cached_list"
|
||||||
const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time"
|
const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time"
|
||||||
|
|
||||||
|
|
@ -224,23 +237,13 @@ class SimklApi : SyncAPI() {
|
||||||
|
|
||||||
/** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */
|
/** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */
|
||||||
data class SettingsResponse(
|
data class SettingsResponse(
|
||||||
@JsonProperty("user")
|
val user: User
|
||||||
val user: User,
|
|
||||||
@JsonProperty("account")
|
|
||||||
val account: Account,
|
|
||||||
) {
|
) {
|
||||||
data class User(
|
data class User(
|
||||||
@JsonProperty("name")
|
|
||||||
val name: String,
|
val name: String,
|
||||||
/** Url */
|
/** Url */
|
||||||
@JsonProperty("avatar")
|
|
||||||
val avatar: String
|
val avatar: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Account(
|
|
||||||
@JsonProperty("id")
|
|
||||||
val id: Int,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PinAuthResponse(
|
data class PinAuthResponse(
|
||||||
|
|
@ -362,7 +365,7 @@ class SimklApi : SyncAPI() {
|
||||||
class SimklScoreBuilder private constructor() {
|
class SimklScoreBuilder private constructor() {
|
||||||
data class Builder(
|
data class Builder(
|
||||||
private var url: String? = null,
|
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 ids: MediaObject.Ids? = null,
|
||||||
private var score: Int? = null,
|
private var score: Int? = null,
|
||||||
private var status: Int? = null,
|
private var status: Int? = null,
|
||||||
|
|
@ -371,7 +374,7 @@ class SimklApi : SyncAPI() {
|
||||||
// Required for knowing if the status should be overwritten
|
// Required for knowing if the status should be overwritten
|
||||||
private var onList: Boolean = false
|
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 apiUrl(url: String) = apply { this.url = url }
|
||||||
fun ids(ids: MediaObject.Ids) = apply { this.ids = ids }
|
fun ids(ids: MediaObject.Ids) = apply { this.ids = ids }
|
||||||
fun score(score: Int?, oldScore: Int?) = apply {
|
fun score(score: Int?, oldScore: Int?) = apply {
|
||||||
|
|
@ -419,8 +422,8 @@ class SimklApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun execute(): Boolean {
|
suspend fun execute(): Boolean {
|
||||||
val time = getDateTime(APIHolder.unixTime)
|
val time = getDateTime(unixTime)
|
||||||
val headers = this.headers ?: emptyMap()
|
|
||||||
return if (this.status == SimklListStatusType.None.value) {
|
return if (this.status == SimklListStatusType.None.value) {
|
||||||
app.post(
|
app.post(
|
||||||
"$url/sync/history/remove",
|
"$url/sync/history/remove",
|
||||||
|
|
@ -428,7 +431,7 @@ class SimklApi : SyncAPI() {
|
||||||
shows = listOf(HistoryMediaObject(ids = ids)),
|
shows = listOf(HistoryMediaObject(ids = ids)),
|
||||||
movies = emptyList()
|
movies = emptyList()
|
||||||
),
|
),
|
||||||
headers = headers
|
interceptor = interceptor
|
||||||
).isSuccessful
|
).isSuccessful
|
||||||
} else {
|
} else {
|
||||||
val statusResponse = this.status?.let { setStatus ->
|
val statusResponse = this.status?.let { setStatus ->
|
||||||
|
|
@ -449,7 +452,7 @@ class SimklApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
), movies = emptyList()
|
), movies = emptyList()
|
||||||
),
|
),
|
||||||
headers = headers
|
interceptor = interceptor
|
||||||
).isSuccessful
|
).isSuccessful
|
||||||
} ?: true
|
} ?: true
|
||||||
|
|
||||||
|
|
@ -466,7 +469,7 @@ class SimklApi : SyncAPI() {
|
||||||
),
|
),
|
||||||
movies = emptyList()
|
movies = emptyList()
|
||||||
),
|
),
|
||||||
headers = headers
|
interceptor = interceptor
|
||||||
).isSuccessful
|
).isSuccessful
|
||||||
} ?: true
|
} ?: true
|
||||||
|
|
||||||
|
|
@ -493,7 +496,7 @@ class SimklApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
), movies = emptyList()
|
), movies = emptyList()
|
||||||
),
|
),
|
||||||
headers = headers
|
interceptor = interceptor
|
||||||
).isSuccessful
|
).isSuccessful
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
|
|
@ -505,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(
|
suspend fun getEpisodes(
|
||||||
simklId: Int?,
|
simklId: Int?,
|
||||||
type: String?,
|
type: String?,
|
||||||
|
|
@ -569,7 +569,7 @@ class SimklApi : SyncAPI() {
|
||||||
@JsonProperty("year") year: Int?,
|
@JsonProperty("year") year: Int?,
|
||||||
@JsonProperty("ids") ids: Ids?,
|
@JsonProperty("ids") ids: Ids?,
|
||||||
@JsonProperty("rating") val rating: Int,
|
@JsonProperty("rating") val rating: Int,
|
||||||
@JsonProperty("rated_at") val ratedAt: String? = getDateTime(APIHolder.unixTime)
|
@JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime)
|
||||||
) : MediaObject(title, year, ids)
|
) : MediaObject(title, year, ids)
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
|
@ -578,7 +578,7 @@ class SimklApi : SyncAPI() {
|
||||||
@JsonProperty("year") year: Int?,
|
@JsonProperty("year") year: Int?,
|
||||||
@JsonProperty("ids") ids: Ids?,
|
@JsonProperty("ids") ids: Ids?,
|
||||||
@JsonProperty("to") val to: String,
|
@JsonProperty("to") val to: String,
|
||||||
@JsonProperty("watched_at") val watchedAt: String? = getDateTime(APIHolder.unixTime)
|
@JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime)
|
||||||
) : MediaObject(title, year, ids)
|
) : MediaObject(title, year, ids)
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
|
@ -664,7 +664,7 @@ class SimklApi : SyncAPI() {
|
||||||
movie.ids.simkl.toString(),
|
movie.ids.simkl.toString(),
|
||||||
this.watchedEpisodesCount,
|
this.watchedEpisodesCount,
|
||||||
this.totalEpisodesCount,
|
this.totalEpisodesCount,
|
||||||
Score.from10(this.userRating),
|
this.userRating?.times(10),
|
||||||
getUnixTime(lastWatchedAt) ?: 0,
|
getUnixTime(lastWatchedAt) ?: 0,
|
||||||
"Simkl",
|
"Simkl",
|
||||||
TvType.Movie,
|
TvType.Movie,
|
||||||
|
|
@ -697,7 +697,7 @@ class SimklApi : SyncAPI() {
|
||||||
show.ids.simkl.toString(),
|
show.ids.simkl.toString(),
|
||||||
this.watchedEpisodesCount,
|
this.watchedEpisodesCount,
|
||||||
this.totalEpisodesCount,
|
this.totalEpisodesCount,
|
||||||
Score.from10(this.userRating),
|
this.userRating?.times(10),
|
||||||
getUnixTime(lastWatchedAt) ?: 0,
|
getUnixTime(lastWatchedAt) ?: 0,
|
||||||
"Simkl",
|
"Simkl",
|
||||||
TvType.Anime,
|
TvType.Anime,
|
||||||
|
|
@ -746,7 +746,7 @@ class SimklApi : SyncAPI() {
|
||||||
/**
|
/**
|
||||||
* Appends api keys to the requests
|
* Appends api keys to the requests
|
||||||
**/
|
**/
|
||||||
/*private inner class HeaderInterceptor : Interceptor {
|
private inner class HeaderInterceptor : Interceptor {
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" }
|
debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" }
|
||||||
return chain.proceed(
|
return chain.proceed(
|
||||||
|
|
@ -757,12 +757,14 @@ class SimklApi : SyncAPI() {
|
||||||
.build()
|
.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.
|
* Useful to get episodes on demand to prevent unnecessary requests.
|
||||||
|
|
@ -780,7 +782,7 @@ class SimklApi : SyncAPI() {
|
||||||
|
|
||||||
class SimklSyncStatus(
|
class SimklSyncStatus(
|
||||||
override var status: SyncWatchType,
|
override var status: SyncWatchType,
|
||||||
override var score: Score?,
|
override var score: Int?,
|
||||||
val oldScore: Int?,
|
val oldScore: Int?,
|
||||||
override var watchedEpisodes: Int?,
|
override var watchedEpisodes: Int?,
|
||||||
val episodeConstructor: SimklEpisodeConstructor,
|
val episodeConstructor: SimklEpisodeConstructor,
|
||||||
|
|
@ -792,8 +794,7 @@ class SimklApi : SyncAPI() {
|
||||||
val oldStatus: String?
|
val oldStatus: String?
|
||||||
) : SyncAPI.AbstractSyncStatus()
|
) : SyncAPI.AbstractSyncStatus()
|
||||||
|
|
||||||
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
if (auth == null) return null
|
|
||||||
val realIds = readIdFromString(id)
|
val realIds = readIdFromString(id)
|
||||||
|
|
||||||
// Key which assumes all ids are the same each time :/
|
// Key which assumes all ids are the same each time :/
|
||||||
|
|
@ -817,7 +818,7 @@ class SimklApi : SyncAPI() {
|
||||||
searchResult.hasEnded()
|
searchResult.hasEnded()
|
||||||
)
|
)
|
||||||
|
|
||||||
val foundItem = getSyncListSmart(auth)?.let { list ->
|
val foundItem = getSyncListSmart()?.let { list ->
|
||||||
listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show ->
|
listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show ->
|
||||||
realIds.any { (database, id) ->
|
realIds.any { (database, id) ->
|
||||||
show.getIds().matchesId(database, id)
|
show.getIds().matchesId(database, id)
|
||||||
|
|
@ -835,7 +836,7 @@ class SimklApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
?: return null,
|
?: return null,
|
||||||
score = Score.from10(foundItem.userRating),
|
score = foundItem.userRating,
|
||||||
watchedEpisodes = foundItem.watchedEpisodesCount,
|
watchedEpisodes = foundItem.watchedEpisodesCount,
|
||||||
maxEpisodes = searchResult.totalEpisodes,
|
maxEpisodes = searchResult.totalEpisodes,
|
||||||
episodeConstructor = episodeConstructor,
|
episodeConstructor = episodeConstructor,
|
||||||
|
|
@ -846,7 +847,7 @@ class SimklApi : SyncAPI() {
|
||||||
} else {
|
} else {
|
||||||
return SimklSyncStatus(
|
return SimklSyncStatus(
|
||||||
status = SyncWatchType.fromInternalId(SimklListStatusType.None.value),
|
status = SyncWatchType.fromInternalId(SimklListStatusType.None.value),
|
||||||
score = null,
|
score = 0,
|
||||||
watchedEpisodes = 0,
|
watchedEpisodes = 0,
|
||||||
maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes,
|
maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes,
|
||||||
episodeConstructor = episodeConstructor,
|
episodeConstructor = episodeConstructor,
|
||||||
|
|
@ -857,26 +858,22 @@ class SimklApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateStatus(
|
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||||
auth: AuthData?,
|
|
||||||
id: String,
|
|
||||||
newStatus: AbstractSyncStatus
|
|
||||||
): Boolean {
|
|
||||||
val parsedId = readIdFromString(id)
|
val parsedId = readIdFromString(id)
|
||||||
lastScoreTime = APIHolder.unixTime
|
lastScoreTime = unixTime
|
||||||
val simklStatus = newStatus as? SimklSyncStatus
|
val simklStatus = status as? SimklSyncStatus
|
||||||
|
|
||||||
val builder = SimklScoreBuilder.Builder()
|
val builder = SimklScoreBuilder.Builder()
|
||||||
.apiUrl(this.mainUrl)
|
.apiUrl(this.mainUrl)
|
||||||
.score(newStatus.score?.toInt(10), simklStatus?.oldScore)
|
.score(status.score, simklStatus?.oldScore)
|
||||||
.status(
|
.status(
|
||||||
newStatus.status.internalId,
|
status.status.internalId,
|
||||||
(newStatus as? SimklSyncStatus)?.oldStatus?.let { oldStatus ->
|
(status as? SimklSyncStatus)?.oldStatus?.let { oldStatus ->
|
||||||
SimklListStatusType.entries.firstOrNull {
|
SimklListStatusType.entries.firstOrNull {
|
||||||
it.originalName == oldStatus
|
it.originalName == oldStatus
|
||||||
}?.value
|
}?.value
|
||||||
})
|
})
|
||||||
.token(auth?.token ?: return false)
|
.interceptor(interceptor)
|
||||||
.ids(MediaObject.Ids.fromMap(parsedId))
|
.ids(MediaObject.Ids.fromMap(parsedId))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -884,11 +881,10 @@ class SimklApi : SyncAPI() {
|
||||||
val episodes = simklStatus?.episodeConstructor?.getEpisodes()
|
val episodes = simklStatus?.episodeConstructor?.getEpisodes()
|
||||||
|
|
||||||
// All episodes if marked as completed
|
// All episodes if marked as completed
|
||||||
val watchedEpisodes =
|
val watchedEpisodes = if (status.status.internalId == SimklListStatusType.Completed.value) {
|
||||||
if (newStatus.status.internalId == SimklListStatusType.Completed.value) {
|
|
||||||
episodes?.size
|
episodes?.size
|
||||||
} else {
|
} else {
|
||||||
newStatus.watchedEpisodes
|
status.watchedEpisodes
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes)
|
builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes)
|
||||||
|
|
@ -910,26 +906,39 @@ class SimklApi : SyncAPI() {
|
||||||
).parsedSafe()
|
).parsedSafe()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
return app.get(
|
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() }
|
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loginRequest(): AuthLoginPage? {
|
override fun authenticate(activity: FragmentActivity?) {
|
||||||
val lastLoginState = BigInteger(130, SecureRandom()).toString(32)
|
lastLoginState = BigInteger(130, SecureRandom()).toString(32)
|
||||||
val url =
|
val url =
|
||||||
"https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}&state=$lastLoginState"
|
"https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState"
|
||||||
|
openBrowser(url, activity)
|
||||||
return AuthLoginPage(
|
|
||||||
url = url,
|
|
||||||
payload = lastLoginState
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
val params = getDateTime(since)?.let {
|
||||||
mapOf("date_from" to it)
|
mapOf("date_from" to it)
|
||||||
} ?: emptyMap()
|
} ?: emptyMap()
|
||||||
|
|
@ -938,22 +947,23 @@ class SimklApi : SyncAPI() {
|
||||||
return app.get(
|
return app.get(
|
||||||
"$mainUrl/sync/all-items/",
|
"$mainUrl/sync/all-items/",
|
||||||
params = params,
|
params = params,
|
||||||
headers = getHeaders(auth.token)
|
interceptor = interceptor
|
||||||
).parsedSafe()
|
).parsedSafe()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getActivities(token: AuthToken): ActivitiesResponse? {
|
private suspend fun getActivities(): ActivitiesResponse? {
|
||||||
return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe()
|
return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSyncListCached(auth: AuthData): AllItemsResponse? {
|
private fun getSyncListCached(): AllItemsResponse? {
|
||||||
return getKey<AllItemsResponse>(SIMKL_CACHED_LIST, auth.user.id.toString())
|
return getKey(accountId, SIMKL_CACHED_LIST)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSyncListSmart(auth: AuthData): AllItemsResponse? {
|
private suspend fun getSyncListSmart(): AllItemsResponse? {
|
||||||
val activities = getActivities(auth.token)
|
if (token == null) return null
|
||||||
val userId = auth.user.id.toString()
|
|
||||||
val lastCacheUpdate = getKey<Long>(SIMKL_CACHED_LIST_TIME, auth.user.id.toString())
|
val activities = getActivities()
|
||||||
|
val lastCacheUpdate = getKey<Long>(accountId, SIMKL_CACHED_LIST_TIME)
|
||||||
val lastRemoval = listOf(
|
val lastRemoval = listOf(
|
||||||
activities?.tvShows?.removedFromList,
|
activities?.tvShows?.removedFromList,
|
||||||
activities?.anime?.removedFromList,
|
activities?.anime?.removedFromList,
|
||||||
|
|
@ -973,28 +983,26 @@ class SimklApi : SyncAPI() {
|
||||||
debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" }
|
debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" }
|
||||||
val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) {
|
val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) {
|
||||||
debugPrint { "Full list update in ${this.name}." }
|
debugPrint { "Full list update in ${this.name}." }
|
||||||
setKey(SIMKL_CACHED_LIST_TIME, userId, lastRemoval)
|
setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval)
|
||||||
getSyncListSince(auth, null)
|
getSyncListSince(null)
|
||||||
} else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) {
|
} else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) {
|
||||||
debugPrint { "Partial list update in ${this.name}." }
|
debugPrint { "Partial list update in ${this.name}." }
|
||||||
setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate)
|
setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate)
|
||||||
AllItemsResponse.merge(
|
AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate))
|
||||||
getSyncListCached(auth),
|
|
||||||
getSyncListSince(auth, lastCacheUpdate)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
debugPrint { "Cached list update in ${this.name}." }
|
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}" }
|
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
|
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 =
|
val baseMap =
|
||||||
SimklListStatusType.entries
|
SimklListStatusType.entries
|
||||||
|
|
@ -1030,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+).*""")
|
val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""")
|
||||||
return simklUrlRegex.find(url)?.groupValues?.get(1) ?: ""
|
return simklUrlRegex.find(url)?.groupValues?.get(1) ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun pinRequest(): AuthPinData? {
|
override suspend fun getDevicePin(): OAuth2API.PinAuthData? {
|
||||||
val pinAuthResp = app.get(
|
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
|
).parsedSafe<PinAuthResponse>() ?: return null
|
||||||
|
|
||||||
return AuthPinData(
|
return OAuth2API.PinAuthData(
|
||||||
deviceCode = pinAuthResp.deviceCode,
|
deviceCode = pinAuthResp.deviceCode,
|
||||||
userCode = pinAuthResp.userCode,
|
userCode = pinAuthResp.userCode,
|
||||||
verificationUrl = pinAuthResp.verificationUrl,
|
verificationUrl = pinAuthResp.verificationUrl,
|
||||||
|
|
@ -1049,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(
|
val pinAuthResp = app.get(
|
||||||
"$mainUrl/oauth/pin/${payload.userCode}?client_id=$CLIENT_ID"
|
"$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID"
|
||||||
).parsedSafe<PinExchangeResponse>() ?: return null
|
).parsedSafe<PinExchangeResponse>() ?: return false
|
||||||
|
|
||||||
return AuthToken(
|
if (pinAuthResp.accessToken != null) {
|
||||||
accessToken = pinAuthResp.accessToken ?: return null,
|
switchToNewAccount()
|
||||||
)
|
setKey(accountId, SIMKL_TOKEN_KEY, pinAuthResp.accessToken)
|
||||||
|
|
||||||
|
val user = getUser()
|
||||||
|
if (user == null) {
|
||||||
|
removeKey(accountId, SIMKL_TOKEN_KEY)
|
||||||
|
switchToOldAccount()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
setKey(accountId, SIMKL_USER_KEY, user)
|
||||||
val uri = redirectUrl.toUri()
|
registerAccount()
|
||||||
|
requireLibraryRefresh = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun handleRedirect(url: String): Boolean {
|
||||||
|
val uri = url.toUri()
|
||||||
val state = uri.getQueryParameter("state")
|
val state = uri.getQueryParameter("state")
|
||||||
// Ensure consistent state
|
// Ensure consistent state
|
||||||
if (state != payload) return null
|
if (state != lastLoginState) return false
|
||||||
|
lastLoginState = ""
|
||||||
|
|
||||||
val code = uri.getQueryParameter("code") ?: return null
|
val code = uri.getQueryParameter("code") ?: return false
|
||||||
val tokenResponse = app.post(
|
val token = app.post(
|
||||||
"$mainUrl/oauth/token", json = TokenRequest(code)
|
"$mainUrl/oauth/token", json = TokenRequest(code)
|
||||||
).parsedSafe<TokenResponse>() ?: return null
|
).parsedSafe<TokenResponse>() ?: return false
|
||||||
|
|
||||||
return AuthToken(
|
switchToNewAccount()
|
||||||
accessToken = tokenResponse.accessToken,
|
setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken)
|
||||||
)
|
|
||||||
|
val user = getUser()
|
||||||
|
if (user == null) {
|
||||||
|
removeKey(accountId, SIMKL_TOKEN_KEY)
|
||||||
|
switchToOldAccount()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun user(token: AuthToken?): AuthUser? {
|
setKey(accountId, SIMKL_USER_KEY, user)
|
||||||
val user = getUser(token ?: return null)
|
registerAccount()
|
||||||
return AuthUser(
|
requireLibraryRefresh = true
|
||||||
id = user.account.id,
|
|
||||||
name = user.user.name,
|
return true
|
||||||
profilePicture = user.user.avatar
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,27 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubProvider
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
|
|
||||||
class SubSourceApi : SubtitleAPI() {
|
class SubSourceApi : AbstractSubProvider {
|
||||||
override val name = "SubSource"
|
|
||||||
override val idPrefix = "subsource"
|
override val idPrefix = "subsource"
|
||||||
|
val name = "SubSource"
|
||||||
override val requiresLogin = false
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val APIURL = "https://api.subsource.net/api"
|
const val APIURL = "https://api.subsource.net/api"
|
||||||
const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
|
const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(
|
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||||
auth: AuthData?,
|
|
||||||
query: AbstractSubtitleEntities.SubtitleSearch
|
|
||||||
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
|
||||||
|
|
||||||
//Only supports Imdb Id search for now
|
//Only supports Imdb Id search for now
|
||||||
if (query.imdbId == null) return null
|
if (query.imdbId == null) return null
|
||||||
val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang)
|
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
|
||||||
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||||
|
|
||||||
val searchRes = app.post(
|
val searchRes = app.post(
|
||||||
|
|
@ -93,17 +87,15 @@ class SubSourceApi : SubtitleAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun SubtitleResource.getResources(
|
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
|
||||||
auth: AuthData?,
|
|
||||||
subtitle: AbstractSubtitleEntities.SubtitleEntity
|
val parsedSub = parseJson<SubData>(data.data)
|
||||||
) {
|
|
||||||
val parsedSub = parseJson<SubData>(subtitle.data)
|
|
||||||
|
|
||||||
val subRes = app.post(
|
val subRes = app.post(
|
||||||
url = "$APIURL/getSub",
|
url = "$APIURL/getSub",
|
||||||
data = mapOf(
|
data = mapOf(
|
||||||
"movie" to parsedSub.movie,
|
"movie" to parsedSub.movie,
|
||||||
"lang" to subtitle.lang,
|
"lang" to data.lang,
|
||||||
"id" to parsedSub.id
|
"id" to parsedSub.id
|
||||||
)
|
)
|
||||||
).parsedSafe<SubTitleLink>() ?: return
|
).parsedSafe<SubTitleLink>() ?: return
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,88 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.app
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI.LoginInfo
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
|
||||||
import com.lagradost.cloudstream3.TvType
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
class SubDlApi : SubtitleAPI() {
|
class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
|
||||||
override val name = "SubDL"
|
|
||||||
override val idPrefix = "subdl"
|
override val idPrefix = "subdl"
|
||||||
|
override val name = "SubDL"
|
||||||
override val icon = R.drawable.subdl_logo_big
|
override val icon = R.drawable.subdl_logo_big
|
||||||
override val hasInApp = true
|
override val requiresPassword = true
|
||||||
override val inAppLoginRequirement = AuthLoginRequirement(password = true, email = true)
|
override val requiresEmail = true
|
||||||
override val requiresLogin = true
|
|
||||||
override val createAccountUrl = "https://subdl.com/panel/register"
|
override val createAccountUrl = "https://subdl.com/panel/register"
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val APIURL = "https://api.subdl.com"
|
const val APIURL = "https://api.subdl.com"
|
||||||
const val APIENDPOINT = "$APIURL/api/v1/subtitles"
|
const val APIENDPOINT = "$APIURL/api/v1/subtitles"
|
||||||
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
|
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
|
||||||
|
const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user"
|
||||||
|
var currentSession: SubtitleOAuthEntity? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun login(form: AuthLoginResponse): AuthToken? {
|
override suspend fun initialize() {
|
||||||
val email = form.email ?: return null
|
currentSession = getAuthKey()
|
||||||
val password = form.password ?: return null
|
}
|
||||||
val tokenResponse = app.post(
|
|
||||||
url = "$APIURL/login",
|
override fun logOut() {
|
||||||
json = mapOf(
|
setAuthKey(null)
|
||||||
"email" to email,
|
removeAccountKeys()
|
||||||
"password" to password
|
currentSession = getAuthKey()
|
||||||
|
}
|
||||||
|
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
|
||||||
|
val email = data.email ?: throw ErrorLoadingException("Requires Email")
|
||||||
|
val password = data.password ?: throw ErrorLoadingException("Requires Password")
|
||||||
|
switchToNewAccount()
|
||||||
|
try {
|
||||||
|
if (initLogin(email, password)) {
|
||||||
|
registerAccount()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
switchToOldAccount()
|
||||||
|
}
|
||||||
|
switchToOldAccount()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
|
||||||
|
val current = getAuthKey() ?: return null
|
||||||
|
return InAppAuthAPI.LoginData(
|
||||||
|
email = current.userEmail,
|
||||||
|
password = current.pass
|
||||||
)
|
)
|
||||||
).parsed<OAuthTokenResponse>()
|
}
|
||||||
|
|
||||||
val apiResponse = app.get(
|
override fun loginInfo(): LoginInfo? {
|
||||||
url = "$APIURL/user/userApi",
|
getAuthKey()?.let { user ->
|
||||||
headers = mapOf(
|
return LoginInfo(
|
||||||
"Authorization" to "Bearer ${tokenResponse.token}"
|
profilePicture = null,
|
||||||
|
name = user.name ?: user.userEmail,
|
||||||
|
accountIndex = accountIndex
|
||||||
)
|
)
|
||||||
).parsed<ApiKeyResponse>()
|
}
|
||||||
|
return null
|
||||||
return AuthToken(accessToken = apiResponse.apiKey, payload = email)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun user(token: AuthToken?): AuthUser? {
|
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||||
val name = token?.payload ?: return null
|
|
||||||
return AuthUser(id = name.hashCode(), name = name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun search(
|
|
||||||
auth : AuthData?,
|
|
||||||
query: AbstractSubtitleEntities.SubtitleSearch
|
|
||||||
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
|
||||||
if (auth == null) return null
|
|
||||||
val apiKey = auth.token.accessToken ?: return null
|
|
||||||
val queryText = query.query
|
val queryText = query.query
|
||||||
val epNum = query.epNumber ?: 0
|
val epNum = query.epNumber ?: 0
|
||||||
val seasonNum = query.seasonNumber ?: 0
|
val seasonNum = query.seasonNumber ?: 0
|
||||||
val yearNum = query.year ?: 0
|
val yearNum = query.year ?: 0
|
||||||
val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang
|
|
||||||
|
|
||||||
val idQuery = when {
|
val idQuery = when {
|
||||||
query.imdbId != null -> "&imdb_id=${query.imdbId}"
|
query.imdbId != null -> "&imdb_id=${query.imdbId}"
|
||||||
|
|
@ -81,8 +96,8 @@ class SubDlApi : SubtitleAPI() {
|
||||||
|
|
||||||
val searchQueryUrl = when (idQuery) {
|
val searchQueryUrl = when (idQuery) {
|
||||||
//Use imdb/tmdb id to search if its valid
|
//Use imdb/tmdb id to search if its valid
|
||||||
null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery"
|
null -> "$APIENDPOINT?api_key=${currentSession?.apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
|
||||||
else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery"
|
else -> "$APIENDPOINT?api_key=${currentSession?.apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
|
||||||
}
|
}
|
||||||
|
|
||||||
val req = app.get(
|
val req = app.get(
|
||||||
|
|
@ -94,9 +109,7 @@ class SubDlApi : SubtitleAPI() {
|
||||||
|
|
||||||
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
|
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
|
||||||
|
|
||||||
val langTagIETF =
|
val lang = subtitle.lang.replaceFirstChar { it.uppercase() }
|
||||||
langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?:
|
|
||||||
subtitle.lang
|
|
||||||
val resEpNum = subtitle.episode ?: query.epNumber
|
val resEpNum = subtitle.episode ?: query.epNumber
|
||||||
val resSeasonNum = subtitle.season ?: query.seasonNumber
|
val resSeasonNum = subtitle.season ?: query.seasonNumber
|
||||||
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||||
|
|
@ -104,7 +117,7 @@ class SubDlApi : SubtitleAPI() {
|
||||||
AbstractSubtitleEntities.SubtitleEntity(
|
AbstractSubtitleEntities.SubtitleEntity(
|
||||||
idPrefix = this.idPrefix,
|
idPrefix = this.idPrefix,
|
||||||
name = subtitle.releaseName,
|
name = subtitle.releaseName,
|
||||||
lang = langTagIETF,
|
lang = lang,
|
||||||
data = "${DOWNLOADENDPOINT}${subtitle.url}",
|
data = "${DOWNLOADENDPOINT}${subtitle.url}",
|
||||||
type = type,
|
type = type,
|
||||||
source = this.name,
|
source = this.name,
|
||||||
|
|
@ -115,155 +128,120 @@ class SubDlApi : SubtitleAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun SubtitleResource.getResources(
|
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
|
||||||
auth: AuthData?,
|
this.addZipUrl(data.data) { name, _ ->
|
||||||
subtitle: AbstractSubtitleEntities.SubtitleEntity
|
|
||||||
) {
|
|
||||||
this.addZipUrl(subtitle.data) { name, _ ->
|
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
private suspend fun initLogin(useremail: String, password: String): Boolean {
|
||||||
|
|
||||||
|
val tokenResponse = app.post(
|
||||||
|
url = "$APIURL/login",
|
||||||
|
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(
|
data class SubtitleOAuthEntity(
|
||||||
@JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String,
|
@JsonProperty("userEmail") var userEmail: String,
|
||||||
@JsonProperty("pass") @SerialName("pass") var pass: String,
|
@JsonProperty("pass") var pass: String,
|
||||||
@JsonProperty("name") @SerialName("name") var name: String? = null,
|
@JsonProperty("name") var name: String? = null,
|
||||||
@JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null,
|
@JsonProperty("accessToken") var accessToken: String? = null,
|
||||||
@JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null,
|
@JsonProperty("apiKey") var apiKey: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class OAuthTokenResponse(
|
data class OAuthTokenResponse(
|
||||||
@JsonProperty("token") @SerialName("token") val token: String,
|
@JsonProperty("token") val token: String? = null,
|
||||||
@JsonProperty("userData") @SerialName("userData") val userData: UserData? = null,
|
@JsonProperty("userData") val userData: UserData? = null,
|
||||||
@JsonProperty("status") @SerialName("status") val status: Boolean? = null,
|
@JsonProperty("status") val status: Boolean? = null,
|
||||||
@JsonProperty("message") @SerialName("message") val message: String? = null,
|
@JsonProperty("message") val message: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UserData(
|
data class UserData(
|
||||||
@JsonProperty("email") @SerialName("email") val email: String,
|
@JsonProperty("email") val email: String,
|
||||||
@JsonProperty("name") @SerialName("name") val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
@JsonProperty("country") @SerialName("country") val country: String,
|
@JsonProperty("country") val country: String,
|
||||||
@JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String,
|
@JsonProperty("scStepCode") val scStepCode: String,
|
||||||
@JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean,
|
@JsonProperty("scVerified") val scVerified: Boolean,
|
||||||
@JsonProperty("username") @SerialName("username") val username: String? = null,
|
@JsonProperty("username") val username: String? = null,
|
||||||
@JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String,
|
@JsonProperty("scUsername") val scUsername: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiKeyResponse(
|
data class ApiKeyResponse(
|
||||||
@JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false,
|
@JsonProperty("ok") val ok: Boolean? = false,
|
||||||
@JsonProperty("api_key") @SerialName("api_key") val apiKey: String,
|
@JsonProperty("api_key") val apiKey: String? = null,
|
||||||
@JsonProperty("usage") @SerialName("usage") val usage: Usage? = null,
|
@JsonProperty("usage") val usage: Usage? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Usage(
|
data class Usage(
|
||||||
@JsonProperty("total") @SerialName("total") val total: Long? = 0,
|
@JsonProperty("total") val total: Long? = 0,
|
||||||
@JsonProperty("today") @SerialName("today") val today: Long? = 0,
|
@JsonProperty("today") val today: Long? = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiResponse(
|
data class ApiResponse(
|
||||||
@JsonProperty("status") @SerialName("status") val status: Boolean? = null,
|
@JsonProperty("status") val status: Boolean? = null,
|
||||||
@JsonProperty("results") @SerialName("results") val results: List<Result>? = null,
|
@JsonProperty("results") val results: List<Result>? = null,
|
||||||
@JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List<Subtitle>? = null,
|
@JsonProperty("subtitles") val subtitles: List<Subtitle>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Result(
|
data class Result(
|
||||||
@JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null,
|
@JsonProperty("sd_id") val sdId: Int? = null,
|
||||||
@JsonProperty("type") @SerialName("type") val type: String? = null,
|
@JsonProperty("type") val type: String? = null,
|
||||||
@JsonProperty("name") @SerialName("name") val name: String? = null,
|
@JsonProperty("name") val name: String? = null,
|
||||||
@JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null,
|
@JsonProperty("imdb_id") val imdbId: String? = null,
|
||||||
@JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null,
|
@JsonProperty("tmdb_id") val tmdbId: Long? = null,
|
||||||
@JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null,
|
@JsonProperty("first_air_date") val firstAirDate: String? = null,
|
||||||
@JsonProperty("year") @SerialName("year") val year: Int? = null,
|
@JsonProperty("year") val year: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Subtitle(
|
data class Subtitle(
|
||||||
@JsonProperty("release_name") @SerialName("release_name") val releaseName: String,
|
@JsonProperty("release_name") val releaseName: String,
|
||||||
@JsonProperty("name") @SerialName("name") val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
@JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code
|
@JsonProperty("lang") val lang: String,
|
||||||
@JsonProperty("author") @SerialName("author") val author: String? = null,
|
@JsonProperty("author") val author: String? = null,
|
||||||
@JsonProperty("url") @SerialName("url") val url: String? = null,
|
@JsonProperty("url") val url: String? = null,
|
||||||
@JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null,
|
@JsonProperty("subtitlePage") val subtitlePage: String? = null,
|
||||||
@JsonProperty("season") @SerialName("season") val season: Int? = null,
|
@JsonProperty("season") val season: Int? = null,
|
||||||
@JsonProperty("episode") @SerialName("episode") val episode: Int? = null,
|
@JsonProperty("episode") val episode: Int? = null,
|
||||||
@JsonProperty("language") @SerialName("language") val language: String? = null, // full language name
|
@JsonProperty("language") val language: String? = null,
|
||||||
@JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null,
|
@JsonProperty("hi") val hearingImpaired: Boolean? = null,
|
||||||
)
|
|
||||||
|
|
||||||
// https://subdl.com/api-files/language_list.json
|
|
||||||
// most of it is IETF BPC 47 conformant tag
|
|
||||||
// but there are some exceptions
|
|
||||||
private val langTagIETF2subdl = mapOf(
|
|
||||||
"en-bg" to "BG_EN", // "Bulgarian_English"
|
|
||||||
"en-de" to "EN_DE", // "English_German"
|
|
||||||
"en-hu" to "HU_EN", // "Hungarian_English"
|
|
||||||
"en-nl" to "NL_EN", // "Dutch_English"
|
|
||||||
"pt-br" to "BR_PT", // "Brazillian Portuguese"
|
|
||||||
"zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?)
|
|
||||||
// "ar" to "AR", // "Arabic"
|
|
||||||
// "az" to "AZ", // "Azerbaijani"
|
|
||||||
// "be" to "BE", // "Belarusian"
|
|
||||||
// "bg" to "BG", // "Bulgarian"
|
|
||||||
// "bn" to "BN", // "Bengali"
|
|
||||||
// "bs" to "BS", // "Bosnian"
|
|
||||||
// "ca" to "CA", // "Catalan"
|
|
||||||
// "cs" to "CS", // "Czech"
|
|
||||||
// "da" to "DA", // "Danish"
|
|
||||||
// "de" to "DE", // "German"
|
|
||||||
// "el" to "EL", // "Greek"
|
|
||||||
// "en" to "EN", // "English"
|
|
||||||
// "eo" to "EO", // "Esperanto"
|
|
||||||
// "es" to "ES", // "Spanish"
|
|
||||||
// "et" to "ET", // "Estonian"
|
|
||||||
// "fa" to "FA", // "Farsi_Persian"
|
|
||||||
// "fi" to "FI", // "Finnish"
|
|
||||||
// "fr" to "FR", // "French"
|
|
||||||
// "he" to "HE", // "Hebrew"
|
|
||||||
// "hi" to "HI", // "Hindi"
|
|
||||||
// "hr" to "HR", // "Croatian"
|
|
||||||
// "hu" to "HU", // "Hungarian"
|
|
||||||
// "id" to "ID", // "Indonesian"
|
|
||||||
// "is" to "IS", // "Icelandic"
|
|
||||||
// "it" to "IT", // "Italian"
|
|
||||||
// "ja" to "JA", // "Japanese"
|
|
||||||
// "ka" to "KA", // "Georgian"
|
|
||||||
// "kl" to "KL", // "Greenlandic"
|
|
||||||
// "ko" to "KO", // "Korean"
|
|
||||||
// "ku" to "KU", // "Kurdish"
|
|
||||||
// "lt" to "LT", // "Lithuanian"
|
|
||||||
// "lv" to "LV", // "Latvian"
|
|
||||||
// "mk" to "MK", // "Macedonian"
|
|
||||||
// "ml" to "ML", // "Malayalam"
|
|
||||||
// "mni" to "MNI", // "Manipuri"
|
|
||||||
// "ms" to "MS", // "Malay"
|
|
||||||
// "my" to "MY", // "Burmese"
|
|
||||||
// "nl" to "NL", // "Dutch"
|
|
||||||
// "no" to "NO", // "Norwegian"
|
|
||||||
// "pl" to "PL", // "Polish"
|
|
||||||
// "pt" to "PT", // "Portuguese"
|
|
||||||
// "ro" to "RO", // "Romanian"
|
|
||||||
// "ru" to "RU", // "Russian"
|
|
||||||
// "si" to "SI", // "Sinhala"
|
|
||||||
// "sk" to "SK", // "Slovak"
|
|
||||||
// "sl" to "SL", // "Slovenian"
|
|
||||||
// "sq" to "SQ", // "Albanian"
|
|
||||||
// "sr" to "SR", // "Serbian"
|
|
||||||
// "sv" to "SV", // "Swedish"
|
|
||||||
// "ta" to "TA", // "Tamil"
|
|
||||||
// "te" to "TE", // "Telugu"
|
|
||||||
// "th" to "TH", // "Thai"
|
|
||||||
// "tl" to "TL", // "Tagalog"
|
|
||||||
// "tr" to "TR", // "Turkish"
|
|
||||||
// "uk" to "UK", // "Ukranian"
|
|
||||||
// "ur" to "UR", // "Urdu"
|
|
||||||
// "vi" to "VI", // "Vietnamese"
|
|
||||||
// "zh" to "ZH", // "Chinese BG code"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,22 @@ import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||||
import com.lagradost.cloudstream3.MainPageRequest
|
import com.lagradost.cloudstream3.MainPageRequest
|
||||||
import com.lagradost.cloudstream3.SearchResponseList
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
import com.lagradost.cloudstream3.SubtitleFile
|
import com.lagradost.cloudstream3.SubtitleFile
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.fixUrl
|
import com.lagradost.cloudstream3.fixUrl
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||||
import com.lagradost.cloudstream3.newSearchResponseList
|
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.GlobalScope.coroutineContext
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
|
|
||||||
class APIRepository(val api: MainAPI) {
|
class APIRepository(val api: MainAPI) {
|
||||||
companion object {
|
companion object {
|
||||||
// 2 minute timeout to prevent bad extensions/extractors from hogging the resources
|
|
||||||
// No real provider should take longer, so we hard kill them.
|
|
||||||
private const val DEFAULT_TIMEOUT = 120_000L
|
|
||||||
private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT
|
|
||||||
private const val MIN_TIMEOUT = 5_000L
|
|
||||||
|
|
||||||
var dubStatusActive = HashSet<DubStatus>()
|
var dubStatusActive = HashSet<DubStatus>()
|
||||||
|
|
||||||
val noneApi = object : MainAPI() {
|
val noneApi = object : MainAPI() {
|
||||||
|
|
@ -55,20 +48,18 @@ class APIRepository(val api: MainAPI) {
|
||||||
val hash: Pair<String, String>
|
val hash: Pair<String, String>
|
||||||
)
|
)
|
||||||
|
|
||||||
private val cache = atomicListOf<SavedLoadResponse>()
|
private val cache = threadSafeListOf<SavedLoadResponse>()
|
||||||
private var cacheIndex: Int = 0
|
private var cacheIndex: Int = 0
|
||||||
const val CACHE_SIZE = 20
|
const val CACHE_SIZE = 20
|
||||||
|
|
||||||
fun getTimeout(desired: Long?): Long {
|
|
||||||
return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||||
if (forceReload) {
|
if (forceReload) {
|
||||||
|
synchronized(cache) {
|
||||||
cache.clear()
|
cache.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
afterPluginsLoadedEvent += ::afterPluginsLoaded
|
afterPluginsLoadedEvent += ::afterPluginsLoaded
|
||||||
|
|
@ -84,30 +75,25 @@ class APIRepository(val api: MainAPI) {
|
||||||
|
|
||||||
suspend fun load(url: String): Resource<LoadResponse> {
|
suspend fun load(url: String): Resource<LoadResponse> {
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
withTimeout(getTimeout(api.loadTimeoutMs)) {
|
|
||||||
if (isInvalidData(url)) throw ErrorLoadingException()
|
if (isInvalidData(url)) throw ErrorLoadingException()
|
||||||
val fixedUrl = api.fixUrl(url)
|
val fixedUrl = api.fixUrl(url)
|
||||||
val lookingForHash = Pair(api.name, fixedUrl)
|
val lookingForHash = Pair(api.name, fixedUrl)
|
||||||
|
|
||||||
val cached = cache.withLock {
|
synchronized(cache) {
|
||||||
var found: LoadResponse? = null
|
|
||||||
for (item in cache) {
|
for (item in cache) {
|
||||||
// 10 min save
|
// 10 min save
|
||||||
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
||||||
found = item.response
|
return@safeApiCall item.response
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cached != null) return@withTimeout cached
|
|
||||||
api.load(fixedUrl)?.also { response ->
|
api.load(fixedUrl)?.also { response ->
|
||||||
// Remove all blank tags as early as possible
|
// Remove all blank tags as early as possible
|
||||||
response.tags = response.tags?.filter { it.isNotBlank() }
|
response.tags = response.tags?.filter { it.isNotBlank() }
|
||||||
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
||||||
|
|
||||||
cache.withLock {
|
synchronized(cache) {
|
||||||
if (cache.size > CACHE_SIZE) {
|
if (cache.size > CACHE_SIZE) {
|
||||||
cache[cacheIndex] = add // rolling cache
|
cache[cacheIndex] = add // rolling cache
|
||||||
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
||||||
|
|
@ -118,32 +104,25 @@ class APIRepository(val api: MainAPI) {
|
||||||
} ?: throw ErrorLoadingException()
|
} ?: throw ErrorLoadingException()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun search(query: String, page: Int): Resource<SearchResponseList> {
|
suspend fun search(query: String): Resource<List<SearchResponse>> {
|
||||||
if (query.isEmpty())
|
if (query.isEmpty())
|
||||||
return Resource.Success(newSearchResponseList(emptyList()))
|
return Resource.Success(emptyList())
|
||||||
|
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
withTimeout(getTimeout(api.searchTimeoutMs)) {
|
return@safeApiCall (api.search(query)
|
||||||
(api.search(query, page)
|
|
||||||
?: throw ErrorLoadingException())
|
?: throw ErrorLoadingException())
|
||||||
// .filter { typesActive.contains(it.type) }
|
// .filter { typesActive.contains(it.type) }
|
||||||
}
|
.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun quickSearch(query: String): Resource<SearchResponseList> {
|
suspend fun quickSearch(query: String): Resource<List<SearchResponse>> {
|
||||||
if (query.isEmpty())
|
if (query.isEmpty())
|
||||||
return Resource.Success(newSearchResponseList(emptyList()))
|
return Resource.Success(emptyList())
|
||||||
|
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
withTimeout(getTimeout(api.quickSearchTimeoutMs)) {
|
api.quickSearch(query) ?: throw ErrorLoadingException()
|
||||||
newSearchResponseList(
|
|
||||||
api.quickSearch(query) ?: throw ErrorLoadingException(),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,7 +134,6 @@ class APIRepository(val api: MainAPI) {
|
||||||
|
|
||||||
suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource<List<HomePageResponse?>> {
|
suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource<List<HomePageResponse?>> {
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
withTimeout(getTimeout(api.getMainPageTimeoutMs)) {
|
|
||||||
api.lastHomepageRequest = unixTimeMS
|
api.lastHomepageRequest = unixTimeMS
|
||||||
|
|
||||||
nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data ->
|
nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data ->
|
||||||
|
|
@ -193,7 +171,6 @@ class APIRepository(val api: MainAPI) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun extractorVerifierJob(extractorData: String?) {
|
suspend fun extractorVerifierJob(extractorData: String?) {
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
|
|
@ -209,9 +186,7 @@ class APIRepository(val api: MainAPI) {
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (isInvalidData(data)) return false // this makes providers cleaner
|
if (isInvalidData(data)) return false // this makes providers cleaner
|
||||||
return try {
|
return try {
|
||||||
withTimeout(getTimeout(api.loadLinksTimeoutMs)) {
|
|
||||||
api.loadLinks(data, isCasting, subtitleCallback, callback)
|
api.loadLinks(data, isCasting, subtitleCallback, callback)
|
||||||
}
|
|
||||||
} catch (throwable: Throwable) {
|
} catch (throwable: Throwable) {
|
||||||
logError(throwable)
|
logError(throwable)
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,34 @@
|
||||||
package com.lagradost.cloudstream3.ui
|
package com.lagradost.cloudstream3.ui
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer
|
import androidx.recyclerview.widget.AsyncListDiffer
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import coil3.dispose
|
|
||||||
import java.util.WeakHashMap
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
|
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
|
||||||
open fun save(): T? = null
|
open fun save(): T? = null
|
||||||
open fun restore(state: T) = Unit
|
open fun restore(state: T) = Unit
|
||||||
|
open fun onViewAttachedToWindow() = Unit
|
||||||
|
open fun onViewDetachedFromWindow() = Unit
|
||||||
|
open fun onViewRecycled() = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class NoStateAdapter<T : Any>(
|
|
||||||
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
|
||||||
) : BaseAdapter<T, Any>(0, diffCallback)
|
|
||||||
|
|
||||||
/** Creates a new shared pool, using the supplied lambda as a constructor.
|
// Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154
|
||||||
*
|
class StateViewModel : ViewModel() {
|
||||||
* The reason for this complicated structure is that a pool should not be shared between contexts
|
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
|
||||||
* as it makes coil fuck up, and theming.
|
|
||||||
* */
|
|
||||||
fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit> =
|
|
||||||
WeakHashMap<Context, RecyclerView.RecycledViewPool>() to lambda
|
|
||||||
|
|
||||||
/** Sets the shared pool of the recyclerview */
|
|
||||||
fun RecyclerView.setRecycledViewPool(pool: Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>) {
|
|
||||||
val ctx = context ?: return
|
|
||||||
synchronized(pool.first) {
|
|
||||||
this.setRecycledViewPool(pool.first.getOrPut(ctx) {
|
|
||||||
RecyclerView.RecycledViewPool().apply(pool.second)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears the shared pool of views */
|
abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>(fragment, 0)
|
||||||
fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>.clear() {
|
|
||||||
synchronized(this.first) {
|
|
||||||
for (pool in this.first.values) {
|
|
||||||
pool?.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
|
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
|
||||||
|
|
@ -70,14 +49,13 @@ fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.Recyc
|
||||||
abstract class BaseAdapter<
|
abstract class BaseAdapter<
|
||||||
T : Any,
|
T : Any,
|
||||||
S : Any>(
|
S : Any>(
|
||||||
|
fragment: Fragment,
|
||||||
val id: Int = 0,
|
val id: Int = 0,
|
||||||
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
||||||
) : RecyclerView.Adapter<ViewHolderState<S>>() {
|
) : RecyclerView.Adapter<ViewHolderState<S>>() {
|
||||||
open val footers: Int = 0
|
open val footers: Int = 0
|
||||||
open val headers: Int = 0
|
open val headers: Int = 0
|
||||||
|
|
||||||
val immutableCurrentList: List<T> get() = mDiffer.currentList
|
|
||||||
|
|
||||||
fun getItem(position: Int): T {
|
fun getItem(position: Int): T {
|
||||||
return mDiffer.currentList[position]
|
return mDiffer.currentList[position]
|
||||||
}
|
}
|
||||||
|
|
@ -107,33 +85,9 @@ abstract class BaseAdapter<
|
||||||
AsyncDifferConfig.Builder(diffCallback).build()
|
AsyncDifferConfig.Builder(diffCallback).build()
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
open fun submitList(list: List<T>?) {
|
||||||
* Instantly submits a **new and fresh** list. This means that no changes like moves are done as
|
|
||||||
* we assume the new list is not the same thing as the old list, nothing is shared.
|
|
||||||
*
|
|
||||||
* The views are rendered instantly as a result, so no fade/pop-ins or similar.
|
|
||||||
*
|
|
||||||
* Use `submitList` for general use, as that can reuse old views.
|
|
||||||
* */
|
|
||||||
open fun submitIncomparableList(list: List<T>?, commitCallback : Runnable? = null) {
|
|
||||||
// This leverages a quirk in the submitList function that has a fast case for null arrays
|
|
||||||
// What this implies is that as long as we do a double submit we can ensure no pop-ins,
|
|
||||||
// as the changes are the entire list instead of calculating deltas
|
|
||||||
submitList(null)
|
|
||||||
submitList(list, commitCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param commitCallback Optional runnable that is executed when the List is committed, if it is committed.
|
|
||||||
* This is needed for some tasks as submitList will use a background thread for diff
|
|
||||||
* */
|
|
||||||
open fun submitList(list: Collection<T>?, commitCallback : Runnable? = null) {
|
|
||||||
// deep copy at least the top list, because otherwise adapter can go crazy
|
// deep copy at least the top list, because otherwise adapter can go crazy
|
||||||
if (list.isNullOrEmpty()) {
|
mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
|
||||||
mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList()
|
|
||||||
} else {
|
|
||||||
mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
|
|
@ -147,25 +101,16 @@ abstract class BaseAdapter<
|
||||||
open fun onBindFooter(holder: ViewHolderState<S>) = Unit
|
open fun onBindFooter(holder: ViewHolderState<S>) = Unit
|
||||||
open fun onBindHeader(holder: ViewHolderState<S>) = Unit
|
open fun onBindHeader(holder: ViewHolderState<S>) = Unit
|
||||||
open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||||
open fun onCreateCustomContent(
|
|
||||||
parent: ViewGroup,
|
|
||||||
viewType: Int
|
|
||||||
) = onCreateContent(parent)
|
|
||||||
|
|
||||||
open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||||
open fun onCreateCustomFooter(
|
|
||||||
parent: ViewGroup,
|
|
||||||
viewType: Int
|
|
||||||
) = onCreateFooter(parent)
|
|
||||||
|
|
||||||
open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||||
open fun onCreateCustomHeader(
|
|
||||||
parent: ViewGroup,
|
|
||||||
viewType: Int
|
|
||||||
) = onCreateHeader(parent)
|
|
||||||
|
|
||||||
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {}
|
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {
|
||||||
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {}
|
holder.onViewAttachedToWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {
|
||||||
|
holder.onViewDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun save(recyclerView: RecyclerView) {
|
fun save(recyclerView: RecyclerView) {
|
||||||
|
|
@ -176,20 +121,21 @@ abstract class BaseAdapter<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearState() {
|
fun clear() {
|
||||||
layoutManagerStates[id]?.clear()
|
stateViewModel.layoutManagerStates[id]?.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun getState(holder: ViewHolderState<S>): S? =
|
private fun getState(holder: ViewHolderState<S>): S? =
|
||||||
layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
|
stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
|
||||||
|
|
||||||
private fun setState(holder: ViewHolderState<S>) {
|
private fun setState(holder: ViewHolderState<S>) {
|
||||||
if (id == 0) return
|
if(id == 0) return
|
||||||
if (!layoutManagerStates.contains(id)) {
|
|
||||||
layoutManagerStates[id] = HashMap()
|
if (!stateViewModel.layoutManagerStates.contains(id)) {
|
||||||
|
stateViewModel.layoutManagerStates[id] = HashMap()
|
||||||
}
|
}
|
||||||
layoutManagerStates[id]?.let { map ->
|
stateViewModel.layoutManagerStates[id]?.let { map ->
|
||||||
map[holder.absoluteAdapterPosition] = holder.save()
|
map[holder.absoluteAdapterPosition] = holder.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,40 +158,30 @@ abstract class BaseAdapter<
|
||||||
super.onDetachedFromRecyclerView(recyclerView)
|
super.onDetachedFromRecyclerView(recyclerView)
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun customContentViewType(item: T): Int = 0
|
|
||||||
open fun customFooterViewType(): Int = 0
|
|
||||||
open fun customHeaderViewType(): Int = 0
|
|
||||||
|
|
||||||
final override fun getItemViewType(position: Int): Int {
|
final override fun getItemViewType(position: Int): Int {
|
||||||
if (position < headers) {
|
if (position < headers) {
|
||||||
return HEADER or customHeaderViewType()
|
return HEADER
|
||||||
}
|
}
|
||||||
val realPosition = position - headers
|
if (position - headers >= mDiffer.currentList.size) {
|
||||||
if (realPosition >= mDiffer.currentList.size) {
|
return FOOTER
|
||||||
return FOOTER or customFooterViewType()
|
|
||||||
}
|
}
|
||||||
return CONTENT or customContentViewType(getItem(realPosition))
|
|
||||||
|
return CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val stateViewModel: StateViewModel by fragment.viewModels()
|
||||||
|
|
||||||
final override fun onViewRecycled(holder: ViewHolderState<S>) {
|
final override fun onViewRecycled(holder: ViewHolderState<S>) {
|
||||||
setState(holder)
|
setState(holder)
|
||||||
onClearView(holder)
|
holder.onViewRecycled()
|
||||||
super.onViewRecycled(holder)
|
super.onViewRecycled(holder)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data.
|
|
||||||
*
|
|
||||||
* If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources.
|
|
||||||
*
|
|
||||||
* Use this with `clearImage`
|
|
||||||
* */
|
|
||||||
open fun onClearView(holder: ViewHolderState<S>) {}
|
|
||||||
|
|
||||||
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
|
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
|
||||||
return when (viewType and TYPE_MASK) {
|
return when (viewType) {
|
||||||
CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK)
|
CONTENT -> onCreateContent(parent)
|
||||||
HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK)
|
HEADER -> onCreateHeader(parent)
|
||||||
FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK)
|
FOOTER -> onCreateFooter(parent)
|
||||||
else -> throw NotImplementedError()
|
else -> throw NotImplementedError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,7 +196,7 @@ abstract class BaseAdapter<
|
||||||
super.onBindViewHolder(holder, position, payloads)
|
super.onBindViewHolder(holder, position, payloads)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when (getItemViewType(position) and TYPE_MASK) {
|
when (getItemViewType(position)) {
|
||||||
CONTENT -> {
|
CONTENT -> {
|
||||||
val realPosition = position - headers
|
val realPosition = position - headers
|
||||||
val item = getItem(realPosition)
|
val item = getItem(realPosition)
|
||||||
|
|
@ -278,7 +214,7 @@ abstract class BaseAdapter<
|
||||||
}
|
}
|
||||||
|
|
||||||
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
|
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
|
||||||
when (getItemViewType(position) and TYPE_MASK) {
|
when (getItemViewType(position)) {
|
||||||
CONTENT -> {
|
CONTENT -> {
|
||||||
val realPosition = position - headers
|
val realPosition = position - headers
|
||||||
val item = getItem(realPosition)
|
val item = getItem(realPosition)
|
||||||
|
|
@ -300,20 +236,9 @@ abstract class BaseAdapter<
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
|
private const val HEADER: Int = 1
|
||||||
fun clearImage(image: ImageView?) {
|
private const val FOOTER: Int = 2
|
||||||
image?.dispose()
|
private const val CONTENT: Int = 0
|
||||||
}
|
|
||||||
|
|
||||||
// Use the lowermost MASK_SIZE bits for the custom content,
|
|
||||||
// use the uppermost 32 - MASK_SIZE to the type
|
|
||||||
private const val MASK_SIZE = 28
|
|
||||||
private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1
|
|
||||||
private const val TYPE_MASK = CUSTOM_MASK.inv()
|
|
||||||
const val HEADER: Int = 3 shl MASK_SIZE
|
|
||||||
const val FOOTER: Int = 2 shl MASK_SIZE
|
|
||||||
/** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */
|
|
||||||
const val CONTENT: Int = 1 shl MASK_SIZE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,5 +248,5 @@ class BaseDiffCallback<T : Any>(
|
||||||
) : DiffUtil.ItemCallback<T>() {
|
) : DiffUtil.ItemCallback<T>() {
|
||||||
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
|
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
|
||||||
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem)
|
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem)
|
||||||
override fun getChangePayload(oldItem: T, newItem: T): Any? = Any()
|
override fun getChangePayload(oldItem: T, newItem: T): Any = Any()
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,9 @@ import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ListView
|
import android.widget.ListView
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.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.MediaQueueItem
|
||||||
import com.google.android.gms.cast.MediaSeekOptions
|
import com.google.android.gms.cast.MediaSeekOptions
|
||||||
import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF
|
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) :
|
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
||||||
UIController() {
|
UIController() {
|
||||||
|
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
|
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
|
||||||
view.setOnClickListener {
|
view.setOnClickListener {
|
||||||
|
|
@ -234,27 +239,12 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
loadMirror(index + 1)
|
loadMirror(index + 1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val mediaLoadOptions =
|
awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) {
|
||||||
MediaLoadOptions.Builder()
|
|
||||||
.setPlayPosition(startAt)
|
|
||||||
.setAutoplay(true)
|
|
||||||
.build()
|
|
||||||
awaitLinks(
|
|
||||||
remoteMediaClient?.load(
|
|
||||||
mediaItem,
|
|
||||||
mediaLoadOptions
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
loadMirror(index + 1)
|
loadMirror(index + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val mediaLoadOptions =
|
awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) {
|
||||||
MediaLoadOptions.Builder()
|
|
||||||
.setPlayPosition(startAt)
|
|
||||||
.setAutoplay(true)
|
|
||||||
.build()
|
|
||||||
awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) {
|
|
||||||
loadMirror(index + 1)
|
loadMirror(index + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,13 +288,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
val currentDuration = remoteMediaClient?.streamDuration
|
val currentDuration = remoteMediaClient?.streamDuration
|
||||||
val currentPosition = remoteMediaClient?.approximateStreamPosition
|
val currentPosition = remoteMediaClient?.approximateStreamPosition
|
||||||
if (currentDuration != null && currentPosition != null)
|
if (currentDuration != null && currentPosition != null)
|
||||||
DataStoreHelper.setViewPosAndResume(
|
DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration)
|
||||||
epData.id,
|
|
||||||
currentPosition,
|
|
||||||
currentDuration,
|
|
||||||
epData,
|
|
||||||
meta.episodes.getOrNull(index + 1)
|
|
||||||
)
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
logError(t)
|
logError(t)
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +304,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
val isSuccessful = safeApiCall {
|
val isSuccessful = safeApiCall {
|
||||||
generator.generateLinks(
|
generator.generateLinks(
|
||||||
clearCache = false,
|
clearCache = false,
|
||||||
sourceTypes = LOADTYPE_CHROMECAST,
|
allowedTypes = LOADTYPE_CHROMECAST,
|
||||||
callback = {
|
callback = {
|
||||||
it.first?.let { link ->
|
it.first?.let { link ->
|
||||||
currentLinks.add(link)
|
currentLinks.add(link)
|
||||||
|
|
@ -328,9 +312,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
}, subtitleCallback = {
|
}, subtitleCallback = {
|
||||||
currentSubs.add(it)
|
currentSubs.add(it)
|
||||||
},
|
},
|
||||||
offset = 0,
|
isCasting = true)
|
||||||
isCasting = true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortedLinks = sortUrls(currentLinks)
|
val sortedLinks = sortUrls(currentLinks)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.core.content.withStyledAttributes
|
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
@ -155,9 +154,10 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (attrs != null) {
|
if (attrs != null) {
|
||||||
context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) {
|
val attrsArray = intArrayOf(android.R.attr.columnWidth)
|
||||||
columnWidth = getDimensionPixelSize(0, -1)
|
val array = context.obtainStyledAttributes(attrs, attrsArray)
|
||||||
}
|
columnWidth = array.getDimensionPixelSize(0, -1)
|
||||||
|
array.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutManager = manager
|
layoutManager = manager
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue