├── ext
├── .gitignore
├── src
│ └── main
│ │ └── java
│ │ └── dev
│ │ └── brahmkshatriya
│ │ └── echo
│ │ └── extension
│ │ ├── config
│ │ └── BuildConfig.kt
│ │ ├── lastfm
│ │ ├── Util.kt
│ │ └── UrlBuilder.kt
│ │ ├── LastFM.kt
│ │ └── LastFMAPI.kt
└── build.gradle.kts
├── app
├── .gitignore
├── src
│ └── main
│ │ ├── res
│ │ ├── mipmap-xxxhdpi
│ │ │ └── ic_launcher_foreground.webp
│ │ ├── values
│ │ │ └── ic_launcher_background.xml
│ │ └── mipmap-anydpi-v26
│ │ │ └── ic_launcher.xml
│ │ └── AndroidManifest.xml
└── build.gradle.kts
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── README.md
├── .gitignore
├── settings.gradle.kts
├── gradle.properties
├── LICENSE.md
├── .github
└── workflows
│ └── build.yml
├── gradlew.bat
└── gradlew
/ext/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | /debug
3 | /release
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rebelonion/echo-lastfm/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # last.fm plugin for [Echo](https://github.com/brahmkshatriya/echo)
2 |
3 | Extremely simple to use, just log in and your scrobbles will be saved :)
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rebelonion/echo-lastfm/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | .idea
4 | .DS_Store
5 | /build
6 | /captures
7 | .externalNativeBuild
8 | .cxx
9 | local.properties
10 | ext/src/main/java/dev/brahmkshatriya/echo/config
11 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Feb 19 15:14:17 IST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 |
9 | @Suppress("UnstableApiUsage")
10 | dependencyResolutionManagement {
11 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
12 | repositories {
13 | google()
14 | mavenCentral()
15 | maven { url = uri("https://jitpack.io") }
16 | }
17 | }
18 |
19 | rootProject.name = "echo-lastfm"
20 | include(":app")
21 | include(":ext")
22 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2 | android.useAndroidX=true
3 | kotlin.code.style=official
4 | android.nonTransitiveRClass=true
5 |
6 | extType=tracker
7 | extId=lastfm
8 | extClass=LastFM
9 |
10 | extIconUrl=https://i.imgur.com/ArKIJYv.png
11 | extName=Last.fm
12 | extDescription=Last.fm scrobbling and now playing support for Echo
13 |
14 | extAuthor=rebelonion
15 | extAuthorUrl=https://rebelonion.dev
16 |
17 | extRepoUrl=https://github.com/rebelonion/echo-lastfm
18 | extUpdateUrl=https://api.github.com/repos/rebelonion/echo-lastfm/releases
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
9 |
12 |
15 |
16 |
19 |
22 |
23 |
26 |
29 |
32 |
33 |
36 |
39 |
40 |
43 |
46 |
47 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # Unabandon Public License (UPL)
2 |
3 | ```
4 | Preamble
5 | ```
6 | This Unabandon Public License (UPL) is designed to ensure the continued development and public availability of source code based on works released under the GNU General Public License Version 3 (GPLv3) while upholding the core principles of GPLv3. This license extends GPLv3 by mandating public accessibility of source code for any derivative works.
7 |
8 | ```
9 | Body
10 | ```
11 | 1. **Incorporation of GPLv3:** This UPL incorporates all terms and conditions of the GNU General Public License Version 3 (GPLv3) as published by the Free Software Foundation. You can find the complete text of GPLv3 at [https://www.gnu.org/licenses/licenses.en.html](https://www.gnu.org/licenses/licenses.en.html).
12 |
13 | 2. **Public Source Requirement:** In addition to the terms of GPLv3, the source code for any software distributed under this license, including modifications and derivative works, must be publicly available. Public availability means the source code must be accessible to anyone through a publicly accessible repository or download link without any access restrictions or fees.
14 |
15 | 3. **Source Code Availability:** The source code must be made publicly available using a recognized open-source hosting platform (e.g., GitHub, GitLab) or be downloadable from a publicly accessible website. The chosen method must clearly identify the source code and its corresponding licensed work.
16 |
17 | ```
18 | Termination
19 | ```
20 | This UPL terminates automatically if the terms and conditions are not followed by the licensee.
--------------------------------------------------------------------------------
/ext/src/main/java/dev/brahmkshatriya/echo/extension/config/BuildConfig.kt:
--------------------------------------------------------------------------------
1 | package dev.brahmkshatriya.echo.extension.config
2 |
3 | object BuildConfig {
4 | private lateinit var slt: ByteArray
5 |
6 | private fun setSlt(saltBytes: ByteArray) {
7 | slt = saltBytes
8 | }
9 |
10 | private fun dbfsct(obfuscatedData: ByteArray): String {
11 | return obfuscatedData.mapIndexed { index, byte ->
12 | (byte.toInt() xor slt[index % slt.size].toInt()).toByte()
13 | }.toByteArray().decodeToString()
14 | }
15 |
16 | fun getK(): String {
17 | setSlt(byteArrayOf(
18 | 10, 102, 99, -96, 2, -108, 4, 4, -2, -95, 14, -60, 124, 52, -81, 66
19 | ))
20 |
21 | val data = byteArrayOf(
22 | 59, 3, 6, -110, 50, -90, 60, 101, -56, -110, 111, -10, 74, 7, -105, 122,
23 | 60, 3, 2, -60, 97, -14, 98, 98, -52, -57, 59, -90, 73, 85, -97, 113
24 | )
25 |
26 | return dbfsct(data)
27 | }
28 |
29 | fun getScrt(): String {
30 | setSlt(byteArrayOf(
31 | 19, 29, -31, 45, 7, 44, -99, 117, 30, -107, -98, -52, -125, 73, -102, 92
32 | ))
33 |
34 | val data = byteArrayOf(
35 | 35, 127, -48, 20, 49, 72, -5, 68, 127, -89, -5, -6, -25, 43, -87, 56,
36 | 36, 36, -40, 76, 54, 30, -7, 67, 40, -15, -89, -1, -74, 43, -4, 108
37 | )
38 |
39 | return dbfsct(data)
40 | }
41 |
42 | fun isDebug(): Boolean {
43 | return false
44 | }
45 |
46 | fun versionCode(): Int {
47 | return 1
48 | }
49 | }
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: nightly
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | env:
12 | NAME: Extension
13 | TAG: ex
14 | steps:
15 | - name: Checkout repo
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup JDK 17
19 | uses: actions/setup-java@v4
20 | with:
21 | distribution: 'zulu'
22 | java-version: 17
23 | cache: 'gradle'
24 |
25 | - name: Cook Env
26 | run: |
27 | echo -e "## ${{ env.NAME }}\n${{ github.event.head_commit.message }}" > commit.txt
28 | version=$( echo ${{ github.event.head_commit.id }} | cut -c1-7 )
29 | echo "VERSION=v$version" >> $GITHUB_ENV
30 | echo "APP_PATH=app/build/${{ env.TAG }}-$version.eapk" >> $GITHUB_ENV
31 | echo "${{ secrets.KEYSTORE_B64 }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks
32 | mkdir -p ext/src/main/java/dev/brahmkshatriya/echo/extension/config
33 | echo "${{ secrets.BUILD_CONFIG_CONTENT }}" | base64 -d > ext/src/main/java/dev/brahmkshatriya/echo/extension/config/BuildConfig.kt
34 | chmod +x ./gradlew
35 |
36 | - name: Build with Gradle
37 | run: |
38 | ./gradlew assembleRelease \
39 | -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \
40 | -Pandroid.injected.signing.store.password=${{ secrets.PASSWORD_STORE }} \
41 | -Pandroid.injected.signing.key.alias=key0 \
42 | -Pandroid.injected.signing.key.password=${{ secrets.PASSWORD_KEY }}
43 |
44 | cp app/build/outputs/apk/release/app-release.apk ${{ env.APP_PATH }}
45 |
46 | - name: Upload APK
47 | uses: actions/upload-artifact@v4
48 | with:
49 | path: ${{ env.APP_PATH }}
50 |
51 | - name: Create Release
52 | uses: softprops/action-gh-release@v2
53 | with:
54 | make_latest: true
55 | tag_name: ${{ env.VERSION }}
56 | body_path: commit.txt
57 | name: ${{ env.VERSION }}
58 | files: ${{ env.APP_PATH }}
59 |
60 | - name: Delete Old Releases
61 | uses: sgpublic/delete-release-action@master
62 | with:
63 | release-drop: true
64 | release-keep-count: 3
65 | release-drop-tag: true
66 | env:
67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
68 |
69 | - name: Upload APK to Discord
70 | shell: bash
71 | env:
72 | WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
73 | run: |
74 | commit=$(jq -Rsa . <<< "${{ github.event.head_commit.message }}" | tail -c +2 | head -c -2)
75 | message=$(echo "@everyone **${{ env.VERSION }}**\n$commit")
76 | curl -F "payload_json={\"content\":\"${message}\"}" \
77 | -F "file=@${{ env.APP_PATH }}" \
78 | ${{ env.WEBHOOK }}
--------------------------------------------------------------------------------
/ext/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
2 | import java.io.IOException
3 |
4 | plugins {
5 | id("java-library")
6 | id("org.jetbrains.kotlin.jvm")
7 | id("com.gradleup.shadow") version "9.1.0"
8 | kotlin("plugin.serialization") version "1.9.22"
9 | }
10 |
11 | java {
12 | sourceCompatibility = JavaVersion.VERSION_17
13 | targetCompatibility = JavaVersion.VERSION_17
14 | }
15 |
16 | kotlin {
17 | jvmToolchain(17)
18 | }
19 |
20 | dependencies {
21 | compileOnly("dev.brahmkshatriya.echo:common:1.0")
22 | compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.2.10")
23 | }
24 |
25 | val extType: String by project
26 | val extId: String by project
27 | val extClass: String by project
28 |
29 | val extIconUrl: String? by project
30 | val extName: String by project
31 | val extDescription: String? by project
32 |
33 | val extAuthor: String by project
34 | val extAuthorUrl: String? by project
35 |
36 | val extRepoUrl: String? by project
37 | val extUpdateUrl: String? by project
38 |
39 | val gitHash = execute("git", "rev-parse", "HEAD").take(7)
40 | val gitCount = execute("git", "rev-list", "--count", "HEAD").toInt()
41 | val verCode = gitCount
42 | val verName = gitHash
43 |
44 | tasks {
45 | val shadowJar by getting(ShadowJar::class) {
46 | archiveBaseName.set(extId)
47 | archiveVersion.set(verName)
48 | manifest {
49 | attributes(
50 | mapOf(
51 | "Extension-Id" to extId,
52 | "Extension-Type" to extType,
53 | "Extension-Class" to extClass,
54 |
55 | "Extension-Version-Code" to verCode,
56 | "Extension-Version-Name" to verName,
57 |
58 | "Extension-Icon-Url" to extIconUrl,
59 | "Extension-Name" to extName,
60 | "Extension-Description" to extDescription,
61 |
62 | "Extension-Author" to extAuthor,
63 | "Extension-Author-Url" to extAuthorUrl,
64 |
65 | "Extension-Repo-Url" to extRepoUrl,
66 | "Extension-Update-Url" to extUpdateUrl
67 | )
68 | )
69 | }
70 | }
71 | }
72 |
73 | fun execute(vararg command: String): String {
74 | val process = ProcessBuilder(*command)
75 | .redirectOutput(ProcessBuilder.Redirect.PIPE)
76 | .redirectError(ProcessBuilder.Redirect.PIPE)
77 | .start()
78 |
79 | val output = process.inputStream.bufferedReader().readText()
80 | val errorOutput = process.errorStream.bufferedReader().readText()
81 |
82 | val exitCode = process.waitFor()
83 |
84 | if (exitCode != 0) {
85 | throw IOException(
86 | "Command failed with exit code $exitCode. Command: ${command.joinToString(" ")}\n" +
87 | "Stdout:\n$output\n" +
88 | "Stderr:\n$errorOutput"
89 | )
90 | }
91 |
92 | return output.trim()
93 | }
94 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/ext/src/main/java/dev/brahmkshatriya/echo/extension/lastfm/Util.kt:
--------------------------------------------------------------------------------
1 | package dev.brahmkshatriya.echo.extension.lastfm
2 |
3 | import dev.brahmkshatriya.echo.common.settings.Settings
4 | import dev.brahmkshatriya.echo.extension.config.BuildConfig
5 | import dev.brahmkshatriya.echo.extension.LastFMAPI.Companion.PLUGIN_IDENTIFIER
6 | import dev.brahmkshatriya.echo.extension.LastFMAPI.Companion.SAVED_SCROBBLES_SETTINGS_KEY
7 | import dev.brahmkshatriya.echo.extension.Scrobble
8 | import kotlinx.serialization.encodeToString
9 | import kotlinx.serialization.json.Json
10 |
11 | /**
12 | * Logs a message to the console if the app is in debug mode.
13 | * @param message The message to log.
14 | */
15 | fun log(message: String) {
16 | if (BuildConfig.isDebug()) {
17 | println("$PLUGIN_IDENTIFIER: $message")
18 | }
19 | }
20 |
21 | /**
22 | * converts an object to a list of itself.
23 | * The exact same as calling listOf(this) but looks cleaner.
24 | * @return A list containing the object.
25 | */
26 | fun T.listOf(): List = listOf(this)
27 |
28 | /**
29 | * Checks if an object is null.
30 | * @return True if the object is null, false otherwise.
31 | */
32 | fun T?.isNull(): Boolean = this == null
33 |
34 | data class SettingData(
35 | val key: String,
36 | val default: T
37 | ) {
38 | @Suppress("UNCHECKED_CAST")
39 | fun get(settings: Settings): T {
40 | return when (default) {
41 | is Boolean -> (settings.getBoolean(key) ?: default) as T
42 | is String -> (settings.getString(key) ?: default) as T
43 | is Int -> (settings.getInt(key) ?: default) as T
44 | is Set<*> -> (settings.getStringSet(key) ?: default) as T
45 | else -> throw IllegalArgumentException("Unsupported type")
46 | }
47 | }
48 | }
49 |
50 | fun Settings.addScrobble(
51 | scrobble: Scrobble
52 | ) {
53 | val scrobbles: MutableList =
54 | this.getString(SAVED_SCROBBLES_SETTINGS_KEY)?.toScrobbles()?.toMutableList()
55 | ?: mutableListOf()
56 | if (!scrobbles.any { it.artist == scrobble.artist && it.track == scrobble.track && it.addedAt == scrobble.addedAt }) {
57 | scrobbles.add(scrobble)
58 | }
59 | this.putString(SAVED_SCROBBLES_SETTINGS_KEY, scrobbles.toJsonString())
60 | }
61 |
62 | fun Settings.getScrobbles(): List {
63 | return this.getString(SAVED_SCROBBLES_SETTINGS_KEY)?.toScrobbles() ?: emptyList()
64 | }
65 |
66 | fun Settings.removeScrobble(scrobble: Scrobble) {
67 | val scrobbles: MutableList =
68 | this.getString(SAVED_SCROBBLES_SETTINGS_KEY)?.toScrobbles()?.toMutableList()
69 | ?: mutableListOf()
70 | scrobbles.remove(scrobble)
71 | this.putString(SAVED_SCROBBLES_SETTINGS_KEY, scrobbles.toJsonString())
72 | }
73 |
74 | fun List.toJsonString(): String{
75 | return Json.encodeToString(this)
76 | }
77 |
78 | fun String.toScrobbles(): List {
79 | return Json.decodeFromString(this)
80 | }
--------------------------------------------------------------------------------
/ext/src/main/java/dev/brahmkshatriya/echo/extension/lastfm/UrlBuilder.kt:
--------------------------------------------------------------------------------
1 | package dev.brahmkshatriya.echo.extension.lastfm
2 |
3 | import dev.brahmkshatriya.echo.extension.LastFMAPI.Companion.getApiKey
4 | import dev.brahmkshatriya.echo.extension.LastFMAPI.Companion.getSecret
5 | import java.math.BigInteger
6 | import java.net.URLEncoder
7 | import java.nio.charset.StandardCharsets
8 | import java.security.MessageDigest
9 |
10 | class UrlBuilder {
11 | companion object {
12 | /**
13 | * Generates a URL and signature for the given method and parameters. method should not be included in [parameters]
14 | * @param method The method to be called.
15 | * @param parameters The parameters to be passed to the method.
16 | * @return A Pair containing the generated URL and signature.
17 | */
18 | fun generateUrlWithSig(
19 | method: String,
20 | sessionKey: String?,
21 | parameters: MutableMap
22 | ): String {
23 | parameters["api_key"] = getApiKey()
24 | if (!sessionKey.isNullOrBlank()) {
25 | parameters["sk"] = sessionKey
26 | }
27 | val sigMap = parameters.toMutableMap()
28 | sigMap["method"] = method
29 | val sig = generateSignature(sigMap)
30 | parameters["api_sig"] = sig
31 | val url = urlBuilder(method, parameters)
32 | return url
33 | }
34 |
35 | /**
36 | * Generates am MD5 hash signature for the given parameters.
37 | * @param parameters The parameters to be signed.
38 | * @return The generated signature.
39 | */
40 | private fun generateSignature(parameters: MutableMap): String {
41 | val signatureString = StringBuilder()
42 | parameters.toSortedMap().forEach {
43 | if (it.key != "format" && it.key != "callback") {
44 | signatureString.append(it.key)
45 | signatureString.append(it.value)
46 | }
47 | }
48 | signatureString.append(getSecret())
49 | return signatureString.toString().md5Hash()
50 | }
51 |
52 | /**
53 | * Builds a URL for LastFM API calls.
54 | * @param method The method to be called.
55 | * @param parameters The parameters to be passed to the method.
56 | * @return The generated URL.
57 | */
58 | fun urlBuilder(method: String, parameters: MutableMap): String {
59 | val url = StringBuilder()
60 | .append(API_URL)
61 | .append(METHOD_IDENTIFIER)
62 | .append(method)
63 | .append(JSON_IDENTIFIER)
64 | parameters.toSortedMap().forEach {
65 | val encodedKey = URLEncoder.encode(it.key, StandardCharsets.UTF_8.toString())
66 | val encodedValue = URLEncoder.encode(it.value, StandardCharsets.UTF_8.toString())
67 | url.append("&$encodedKey=$encodedValue")
68 | }
69 | return url.toString()
70 | }
71 |
72 | /**
73 | * Generates an MD5 hash of the string.
74 | * @return The MD5 hash.
75 | */
76 | private fun String.md5Hash(): String {
77 | val md5 = MessageDigest.getInstance("MD5")
78 | val bytes = md5.digest(this.toByteArray(Charsets.UTF_8))
79 | val bigInt = BigInteger(1, bytes)
80 | return bigInt.toString(16).padStart(32, '0')
81 | }
82 |
83 | private const val API_URL = "https://ws.audioscrobbler.com/2.0/"
84 | private const val METHOD_IDENTIFIER = "?method="
85 | private const val JSON_IDENTIFIER = "&format=json"
86 | }
87 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import java.io.IOException
2 |
3 | plugins {
4 | id("com.android.application")
5 | id("org.jetbrains.kotlin.android")
6 | }
7 |
8 | dependencies {
9 | implementation(project(":ext"))
10 | compileOnly("dev.brahmkshatriya.echo:common:1.0")
11 | compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.2.10")
12 | }
13 |
14 | val extType: String by project
15 | val extId: String by project
16 | val extClass: String by project
17 |
18 | val extIconUrl: String? by project
19 | val extName: String by project
20 | val extDescription: String? by project
21 |
22 | val extAuthor: String by project
23 | val extAuthorUrl: String? by project
24 |
25 | val extRepoUrl: String? by project
26 | val extUpdateUrl: String? by project
27 |
28 | val gitHash = execute("git", "rev-parse", "HEAD").take(7)
29 | val gitCount = execute("git", "rev-list", "--count", "HEAD").toInt()
30 | val verCode = gitCount
31 | val verName = "v$gitHash"
32 |
33 |
34 | val outputDir = file("${layout.buildDirectory.asFile.get()}/generated/proguard")
35 | val generatedProguard = file("${outputDir}/generated-rules.pro")
36 |
37 | tasks.register("generateProguardRules") {
38 | doLast {
39 | outputDir.mkdirs()
40 | generatedProguard.writeText(
41 | "-dontobfuscate\n-keep,allowoptimization class dev.brahmkshatriya.echo.extension.$extClass"
42 | )
43 | }
44 | }
45 |
46 | tasks.named("preBuild") {
47 | dependsOn("generateProguardRules")
48 | }
49 |
50 | tasks.register("uninstall") {
51 | android.run {
52 | execute(
53 | adbExecutable.absolutePath, "shell", "pm", "uninstall", defaultConfig.applicationId!!
54 | )
55 | }
56 | }
57 |
58 | android {
59 | namespace = "dev.brahmkshatriya.echo.extension"
60 | compileSdk = 36
61 | defaultConfig {
62 | applicationId = "dev.brahmkshatriya.echo.extension.$extId"
63 | minSdk = 24
64 | targetSdk = 36
65 |
66 | manifestPlaceholders.apply {
67 | put("type", "dev.brahmkshatriya.echo.${extType}")
68 | put("id", extId)
69 | put("class_path", "dev.brahmkshatriya.echo.extension.${extClass}")
70 | put("version", verName)
71 | put("version_code", verCode.toString())
72 | put("icon_url", extIconUrl ?: "")
73 | put("app_name", "Echo : $extName Extension")
74 | put("name", extName)
75 | put("description", extDescription ?: "")
76 | put("author", extAuthor)
77 | put("author_url", extAuthorUrl ?: "")
78 | put("repo_url", extRepoUrl ?: "")
79 | put("update_url", extUpdateUrl ?: "")
80 | }
81 | }
82 |
83 | buildTypes {
84 | getByName("release") {
85 | signingConfig = signingConfigs.getByName("debug")
86 | }
87 | all {
88 | isMinifyEnabled = true
89 | proguardFiles(
90 | getDefaultProguardFile("proguard-android-optimize.txt"),
91 | generatedProguard.absolutePath
92 | )
93 | }
94 | }
95 |
96 | compileOptions {
97 | sourceCompatibility = JavaVersion.VERSION_17
98 | targetCompatibility = JavaVersion.VERSION_17
99 | }
100 |
101 | kotlinOptions {
102 | jvmTarget = JavaVersion.VERSION_17.toString()
103 | }
104 | }
105 |
106 | fun execute(vararg command: String): String {
107 | val process = ProcessBuilder(*command)
108 | .redirectOutput(ProcessBuilder.Redirect.PIPE)
109 | .redirectError(ProcessBuilder.Redirect.PIPE)
110 | .start()
111 |
112 | val output = process.inputStream.bufferedReader().readText()
113 | val errorOutput = process.errorStream.bufferedReader().readText()
114 |
115 | val exitCode = process.waitFor()
116 |
117 | if (exitCode != 0) {
118 | throw IOException(
119 | "Command failed with exit code $exitCode. Command: ${command.joinToString(" ")}\n" +
120 | "Stdout:\n$output\n" +
121 | "Stderr:\n$errorOutput"
122 | )
123 | }
124 |
125 | return output.trim()
126 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/ext/src/main/java/dev/brahmkshatriya/echo/extension/LastFM.kt:
--------------------------------------------------------------------------------
1 | package dev.brahmkshatriya.echo.extension
2 |
3 | import dev.brahmkshatriya.echo.common.clients.ExtensionClient
4 | import dev.brahmkshatriya.echo.common.clients.LoginClient
5 | import dev.brahmkshatriya.echo.common.clients.TrackerClient
6 | import dev.brahmkshatriya.echo.common.clients.TrackerMarkClient
7 | import dev.brahmkshatriya.echo.common.models.TrackDetails
8 | import dev.brahmkshatriya.echo.common.models.User
9 | import dev.brahmkshatriya.echo.common.settings.Setting
10 | import dev.brahmkshatriya.echo.common.settings.SettingSlider
11 | import dev.brahmkshatriya.echo.common.settings.SettingSwitch
12 | import dev.brahmkshatriya.echo.common.settings.Settings
13 | import dev.brahmkshatriya.echo.extension.lastfm.SettingData
14 | import dev.brahmkshatriya.echo.extension.lastfm.getScrobbles
15 | import dev.brahmkshatriya.echo.extension.lastfm.listOf
16 | import dev.brahmkshatriya.echo.extension.lastfm.removeScrobble
17 | import java.io.IOException
18 |
19 | @Suppress("unused")
20 | class LastFM : ExtensionClient, LoginClient.CustomInput, TrackerClient, TrackerMarkClient {
21 | private val api = LastFMAPI()
22 |
23 | override suspend fun getMarkAsPlayedDuration(details: TrackDetails): Long? {
24 | details.track.duration?.let {
25 | if (it <= 0) return MARK_AS_PLAYED_DURATION_SETTING.default * 1000L
26 | return markAsPlayedDuration(it)
27 | }
28 | return MARK_AS_PLAYED_DURATION_SETTING.default * 1000L
29 | }
30 |
31 | override suspend fun onExtensionSelected() {}
32 |
33 | override suspend fun onPlayingStateChanged(details: TrackDetails?, isPlaying: Boolean) {
34 |
35 | }
36 |
37 | override suspend fun onMarkAsPlayed(details: TrackDetails) {
38 | val timestamp = System.currentTimeMillis() / 1000 - 30
39 | var artists = details.getArtistString()
40 | if (artists.isBlank() && details.track.title.isBlank()) return
41 | if (artists.isBlank()) artists = details.track.title
42 | val success = api.sendScrobble(
43 | Scrobble(
44 | artist = artists,
45 | track = details.track.title,
46 | timestamp = timestamp,
47 | album = details.track.album?.title
48 | )
49 | )
50 | if (success) {
51 | val scrobbles = setting.getScrobbles()
52 | if (scrobbles.isNotEmpty()) {
53 | for (scrobble in scrobbles) {
54 | var shouldRemove = false
55 | try {
56 | api.sendScrobble(scrobble)
57 | shouldRemove = true
58 | } catch (e: Exception) {
59 | if (e !is IOException) {
60 | shouldRemove = true
61 | }
62 | }
63 |
64 | if (shouldRemove) {
65 | setting.removeScrobble(scrobble)
66 | }
67 | }
68 | }
69 |
70 | }
71 | }
72 |
73 | override suspend fun onTrackChanged(details: TrackDetails?) {
74 | if (!isNowPlayingEnabled()) return
75 | val title = details?.track?.title ?: return
76 | var artists = details.getArtistString()
77 | if (artists.isBlank()) artists = title
78 | api.sendNowPlaying(
79 | Scrobble(
80 | artist = artists,
81 | track = title,
82 | timestamp = System.currentTimeMillis() / 1000 - 30,
83 | album = details.track.album?.title
84 | )
85 | )
86 | }
87 |
88 | override suspend fun getCurrentUser(): User? {
89 | return api.getUser()
90 | }
91 |
92 | override val forms: List
93 | get() = LoginClient.Form(
94 | key = "login",
95 | label = "Login",
96 | icon = LoginClient.InputField.Type.Username,
97 | inputFields = listOf(
98 | LoginClient.InputField(
99 | type = LoginClient.InputField.Type.Username,
100 | key = "username",
101 | label = "Username (not email)",
102 | isRequired = true,
103 | ),
104 | LoginClient.InputField(
105 | type = LoginClient.InputField.Type.Password,
106 | key = "password",
107 | label = "Password",
108 | isRequired = true,
109 | )
110 | )
111 | ).listOf()
112 |
113 | override suspend fun onLogin(key: String, data: Map): List {
114 | val username = data["username"] ?: return emptyList()
115 | val password = data["password"] ?: return emptyList()
116 | val user = api.login(username, password)
117 | api.updateUser(user)
118 | return user.listOf()
119 | }
120 |
121 | override fun setLoginUser(user: User?) {
122 | if (user?.id == null) {
123 | api.updateUser(null)
124 | } else {
125 | api.updateUser(user)
126 | }
127 | }
128 |
129 | override suspend fun getSettingItems(): List {
130 | return listOf(
131 | SettingSlider(
132 | key = MARK_AS_PLAYED_DURATION_SETTING.key,
133 | title = "Mark as Played Duration (in seconds)",
134 | summary = "Duration after which a track is marked as played",
135 | defaultValue = 60,
136 | from = 10,
137 | to = 300,
138 | steps = 10
139 | ),
140 | SettingSlider(
141 | key = MARK_AS_PLAYED_PERCENTAGE_SETTING.key,
142 | title = "Mark as Played Percentage",
143 | summary = "Percentage of track duration after which a track is marked as played",
144 | defaultValue = 50,
145 | from = 20,
146 | to = 100,
147 | ),
148 | SettingSwitch(
149 | key = ONLY_USE_FIRST_ARTIST_SETTING.key,
150 | title = "Only use first artist",
151 | summary = "If a track has multiple artists, only the first one will be used",
152 | defaultValue = true
153 | ),
154 | SettingSwitch(
155 | key = ENABLE_NOW_PLAYING_SETTING.key,
156 | title = "Enable Now Playing",
157 | summary = "Send Now Playing updates to Last.fm",
158 | defaultValue = true
159 | )
160 | )
161 | }
162 |
163 | private lateinit var setting: Settings
164 | override fun setSettings(settings: Settings) {
165 | setting = settings
166 | api.updateSettings(settings)
167 | }
168 |
169 | fun markAsPlayedDuration(songDuration: Long): Long {
170 | val durationSetting = MARK_AS_PLAYED_DURATION_SETTING.get(setting)
171 | val percentageSetting = MARK_AS_PLAYED_PERCENTAGE_SETTING.get(setting)
172 | val percentageDuration = (songDuration * percentageSetting) / 100
173 | return minOf(durationSetting * 1000L, percentageDuration)
174 | }
175 |
176 | fun TrackDetails.getArtistString(): String {
177 | return if (ONLY_USE_FIRST_ARTIST_SETTING.get(setting)) {
178 | this.track.artists.firstOrNull()?.name ?: ""
179 | } else {
180 | this.track.artists.joinToString(",") { it.name }
181 | }
182 | }
183 |
184 | fun isNowPlayingEnabled(): Boolean {
185 | return ENABLE_NOW_PLAYING_SETTING.get(setting)
186 | }
187 |
188 | companion object {
189 | val MARK_AS_PLAYED_DURATION_SETTING: SettingData =
190 | SettingData("mark_as_played_duration", 60)
191 | val MARK_AS_PLAYED_PERCENTAGE_SETTING: SettingData =
192 | SettingData("mark_as_played_percentage", 50)
193 | val ONLY_USE_FIRST_ARTIST_SETTING: SettingData =
194 | SettingData("only_use_first_artist", true)
195 | val ENABLE_NOW_PLAYING_SETTING: SettingData =
196 | SettingData("enable_now_playing", true)
197 | }
198 |
199 | }
--------------------------------------------------------------------------------
/ext/src/main/java/dev/brahmkshatriya/echo/extension/LastFMAPI.kt:
--------------------------------------------------------------------------------
1 | package dev.brahmkshatriya.echo.extension
2 |
3 | import dev.brahmkshatriya.echo.common.helpers.ClientException
4 | import dev.brahmkshatriya.echo.common.helpers.ContinuationCallback.Companion.await
5 | import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder
6 | import dev.brahmkshatriya.echo.common.models.User
7 | import dev.brahmkshatriya.echo.common.settings.Settings
8 | import dev.brahmkshatriya.echo.extension.config.BuildConfig
9 | import dev.brahmkshatriya.echo.extension.lastfm.UrlBuilder.Companion.generateUrlWithSig
10 | import dev.brahmkshatriya.echo.extension.lastfm.UrlBuilder.Companion.urlBuilder
11 | import dev.brahmkshatriya.echo.extension.lastfm.addScrobble
12 | import dev.brahmkshatriya.echo.extension.lastfm.isNull
13 | import dev.brahmkshatriya.echo.extension.lastfm.log
14 | import kotlinx.serialization.Serializable
15 | import kotlinx.serialization.json.Json
16 | import kotlinx.serialization.json.contentOrNull
17 | import kotlinx.serialization.json.intOrNull
18 | import kotlinx.serialization.json.jsonArray
19 | import kotlinx.serialization.json.jsonObject
20 | import kotlinx.serialization.json.jsonPrimitive
21 | import okhttp3.OkHttpClient
22 | import okhttp3.Request
23 | import okhttp3.RequestBody
24 | import okhttp3.RequestBody.Companion.toRequestBody
25 | import java.io.IOException
26 | import java.util.concurrent.TimeUnit.SECONDS
27 |
28 | @Serializable
29 | data class Scrobble(
30 | val artist: String,
31 | val track: String,
32 | val timestamp: Long,
33 | val album: String? = null,
34 | val addedAt: Long = System.currentTimeMillis() / 1000 / 60, // Only save one track per minute
35 | )
36 |
37 | class LastFMAPI {
38 | private var user: User? = null
39 | private var settings: Settings? = null
40 | private val client = OkHttpClient.Builder()
41 | .connectTimeout(10, SECONDS)
42 | .readTimeout(10, SECONDS)
43 | .writeTimeout(10, SECONDS).build()
44 |
45 | suspend fun sendNowPlaying(scrobble: Scrobble) {
46 | if (getSessionKey().isNullOrBlank()) throw loginRequiredException()
47 | val parameters: MutableMap = mutableMapOf()
48 | parameters["artist"] = scrobble.artist
49 | parameters["track"] = scrobble.track
50 | if (!scrobble.album.isNullOrBlank()) {
51 | parameters["album"] = scrobble.album
52 | }
53 | val method = "track.updateNowPlaying"
54 | val url = generateUrlWithSig(method, getSessionKey(), parameters)
55 | val (code, body) = try{
56 | sendRequest(url)
57 | } catch (e: IOException) { // We're going to assume this means no internet
58 | log("Network error: ${e.message}")
59 | return // Hiding it so user doesn't get spammed with errors when offline
60 | }
61 | if (code.isNull()) {
62 | log(body)
63 | } else {
64 | log("Now Playing Error: Unexpected code $code")
65 | log(body)
66 | throw Exception("Now Playing Error $code: $body")
67 | }
68 | }
69 |
70 | suspend fun sendScrobble(scrobble: Scrobble): Boolean {
71 | if (getSessionKey().isNullOrBlank()) throw loginRequiredException()
72 | val parameters: MutableMap = mutableMapOf()
73 | parameters["artist"] = scrobble.artist
74 | parameters["track"] = scrobble.track
75 | parameters["timestamp"] = scrobble.timestamp.toString()
76 | if (!scrobble.album.isNullOrBlank()) {
77 | parameters["album"] = scrobble.album
78 | }
79 | val method = "track.scrobble"
80 | val url = generateUrlWithSig(method, getSessionKey(), parameters)
81 | val (code, body) = try {
82 | sendRequest(url)
83 | } catch (e: IOException) {
84 | log("Network error: ${e.message}")
85 | settings?.addScrobble(scrobble)
86 | return false
87 | }
88 | if (code.isNull()) {
89 | log(body)
90 | } else {
91 | log("Scrobble Error: Unexpected code $code")
92 | log(body)
93 | throw Exception("Scrobble Error $code}: $body")
94 | }
95 | return true
96 | }
97 |
98 | suspend fun login(username: String, password: String): User {
99 | val parameters: MutableMap = mutableMapOf()
100 | parameters["username"] = username
101 | parameters["password"] = password
102 | val method = "auth.getMobileSession"
103 | val url = generateUrlWithSig(method, null, parameters)
104 | val (code, body) = sendRequest(url)
105 | val json = Json { ignoreUnknownKeys = true }
106 | val jsonObject = json.parseToJsonElement(body).jsonObject
107 |
108 | return if (code.isNull()) {
109 | log(body)
110 | val sessionJson = jsonObject["session"]?.jsonObject
111 | ?: throw Exception("Session data not found")
112 |
113 | val name = sessionJson["name"]?.jsonPrimitive?.contentOrNull
114 | ?: throw Exception("Name not found in session")
115 |
116 | val key = sessionJson["key"]?.jsonPrimitive?.contentOrNull
117 | ?: throw Exception("Key not found in session")
118 |
119 | val image = getPFP(name)
120 | User(key, name, image?.toImageHolder())
121 | } else {
122 | log("Unexpected code $code")
123 | log(body)
124 | val errorCode = jsonObject["error"]?.jsonPrimitive?.intOrNull
125 | ?: throw Exception("Error code not found")
126 |
127 | val message = jsonObject["message"]?.jsonPrimitive?.contentOrNull
128 | ?: throw Exception("Error message not found")
129 |
130 | throw Exception("Error $errorCode: $message")
131 | }
132 | }
133 |
134 | private suspend fun getPFP(username: String): String? {
135 | val parameters: MutableMap = mutableMapOf()
136 | parameters["user"] = username
137 | parameters["api_key"] = getApiKey()
138 | val url = urlBuilder("user.getinfo", parameters)
139 | val (_, body) = sendRequest(url)
140 | val json = Json { ignoreUnknownKeys = true }
141 | val userObject = json.parseToJsonElement(body).jsonObject["user"]?.jsonObject
142 | ?: throw Exception("User data not found")
143 | val images = userObject["image"]?.jsonArray
144 | ?: throw Exception("Image data not found")
145 |
146 | var mediumImage: String? = null
147 | var largeImage: String? = null
148 |
149 | for (i in images.indices) {
150 | val imageObject = images[i].jsonObject
151 | val size = imageObject["size"]?.jsonPrimitive?.contentOrNull
152 | ?: throw Exception("Size not found in image data")
153 | val imUrl = imageObject["#text"]?.jsonPrimitive?.contentOrNull
154 | ?: throw Exception("Image URL not found in image data")
155 |
156 | when (size) {
157 | "medium" -> mediumImage = imUrl
158 | "large" -> largeImage = imUrl
159 | }
160 | }
161 |
162 | return mediumImage ?: largeImage
163 | }
164 |
165 | fun updateUser(user: User?) {
166 | this.user = user
167 | }
168 |
169 | fun getUser(): User? {
170 | return user
171 | }
172 |
173 | fun updateSettings(settings: Settings?) {
174 | this.settings = settings
175 | }
176 |
177 | private fun getSessionKey(): String? {
178 | return user?.id
179 | }
180 |
181 | private fun buildRequest(url: String, body: RequestBody? = null): Request {
182 | val sendBody = body ?: byteArrayOf().toRequestBody(null, 0, 0)
183 | return Request.Builder().url(url).post(sendBody).header("User-Agent", USER_AGENT).build()
184 | }
185 |
186 | private suspend fun sendRequest(url: String, body: RequestBody? = null): Pair {
187 | val request = buildRequest(url, body)
188 | val response = client.newCall(request).await()
189 | val responseBody = response.body.string()
190 | val code = if (!response.isSuccessful)
191 | response.code
192 | else
193 | null
194 | response.close()
195 | return Pair(code, responseBody)
196 | }
197 |
198 | private fun loginRequiredException() =
199 | ClientException.LoginRequired()
200 |
201 | companion object {
202 | const val PLUGIN_IDENTIFIER = "Echo-Lastfm-Plugin"
203 | const val SAVED_SCROBBLES_SETTINGS_KEY = "saved_scrobbles"
204 | private val USER_AGENT =
205 | "$PLUGIN_IDENTIFIER/${BuildConfig.versionCode()} (${System.getProperty("os.name")}:${System.getProperty("os.version")})"
206 |
207 | fun getApiKey(): String {
208 | return BuildConfig.getK()
209 | }
210 |
211 | fun getSecret(): String {
212 | return BuildConfig.getScrt()
213 | }
214 | }
215 | }
--------------------------------------------------------------------------------