├── 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 | } --------------------------------------------------------------------------------