├── rust ├── .gitignore ├── Cargo.toml ├── src │ ├── session.rs │ ├── main.rs │ └── lib.rs └── Cargo.lock ├── settings.gradle ├── src ├── test │ ├── resources │ │ └── dbus │ │ │ ├── playing.variant │ │ │ └── metadata.variant │ └── java │ │ ├── platform │ │ ├── SpotifyDirectTest.java │ │ ├── linux │ │ │ └── SpotifyDBusParserTest.java │ │ ├── windows │ │ │ ├── SpotifyProcessTest.java │ │ │ └── WindowMediaSessionTest.java │ │ ├── SpotifyPlayPauseTest.java │ │ └── SpotifyListenerTest.java │ │ ├── OpenSpotifyApiTest.java │ │ └── SpotifyEnableDevMode.java └── main │ ├── java │ └── de │ │ └── labystudio │ │ └── spotifyapi │ │ ├── model │ │ ├── MediaKey.java │ │ └── Track.java │ │ ├── open │ │ ├── model │ │ │ ├── AccessTokenResponse.java │ │ │ └── track │ │ │ │ ├── ExternalIds.java │ │ │ │ ├── ExternalUrls.java │ │ │ │ ├── Image.java │ │ │ │ ├── Artist.java │ │ │ │ ├── Album.java │ │ │ │ └── OpenTrack.java │ │ ├── totp │ │ │ ├── provider │ │ │ │ ├── DefaultSecretProvider.java │ │ │ │ └── SecretProvider.java │ │ │ ├── gson │ │ │ │ ├── SecretSerializer.java │ │ │ │ └── SecretDeserializer.java │ │ │ ├── model │ │ │ │ ├── SecretStorage.java │ │ │ │ └── Secret.java │ │ │ └── TOTP.java │ │ ├── Cache.java │ │ └── OpenSpotifyAPI.java │ │ ├── platform │ │ ├── linux │ │ │ ├── api │ │ │ │ ├── model │ │ │ │ │ ├── InterfaceMember.java │ │ │ │ │ ├── Parameter.java │ │ │ │ │ ├── Metadata.java │ │ │ │ │ └── Variant.java │ │ │ │ ├── MPRISCommunicator.java │ │ │ │ └── DBusSend.java │ │ │ └── LinuxSpotifyApi.java │ │ ├── windows │ │ │ ├── api │ │ │ │ ├── playback │ │ │ │ │ ├── PlaybackAccessor.java │ │ │ │ │ └── source │ │ │ │ │ │ ├── LegacyPlaybackAccessor.java │ │ │ │ │ │ └── MediaControlPlaybackAccessor.java │ │ │ │ ├── jna │ │ │ │ │ ├── WindowsMediaControl.java │ │ │ │ │ ├── Kernel32.java │ │ │ │ │ ├── Psapi.java │ │ │ │ │ └── Tlhelp32.java │ │ │ │ ├── spotify │ │ │ │ │ ├── SpotifyWindowTitle.java │ │ │ │ │ └── SpotifyProcess.java │ │ │ │ └── WinApi.java │ │ │ └── WinSpotifyAPI.java │ │ ├── osx │ │ │ ├── api │ │ │ │ ├── Action.java │ │ │ │ ├── AppleScript.java │ │ │ │ └── spotify │ │ │ │ │ └── SpotifyAppleScript.java │ │ │ └── OSXSpotifyApi.java │ │ └── AbstractTickSpotifyAPI.java │ │ ├── SpotifyListenerAdapter.java │ │ ├── SpotifyListener.java │ │ ├── config │ │ └── SpotifyConfiguration.java │ │ ├── SpotifyAPIFactory.java │ │ └── SpotifyAPI.java │ └── resources │ ├── natives │ └── windows-x64 │ │ └── windowsmediacontrol.dll │ └── secrets.json ├── .github ├── assets │ └── banner.png └── workflows │ └── gradle.yml ├── .gitignore ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── jitpack.yml ├── gradlew.bat ├── README.md └── gradlew /rust/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | *.bat 3 | *.sh 4 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'java-spotify-api' 2 | 3 | -------------------------------------------------------------------------------- /src/test/resources/dbus/playing.variant: -------------------------------------------------------------------------------- 1 | variant string "Playing" -------------------------------------------------------------------------------- /.github/assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LabyStudio/java-spotify-api/HEAD/.github/assets/banner.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | .settings/* 4 | .idea 5 | /bin/ 6 | out 7 | .gradle 8 | *.iml 9 | build 10 | run/ -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LabyStudio/java-spotify-api/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | before_install: 2 | - wget https://github.com/sormuras/bach/raw/master/install-jdk.sh 3 | - source install-jdk.sh --feature 16 4 | - jshell --version -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/model/MediaKey.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.model; 2 | 3 | public enum MediaKey { 4 | PLAY_PAUSE, 5 | NEXT, 6 | PREV 7 | } 8 | -------------------------------------------------------------------------------- /src/main/resources/natives/windows-x64/windowsmediacontrol.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LabyStudio/java-spotify-api/HEAD/src/main/resources/natives/windows-x64/windowsmediacontrol.dll -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/model/AccessTokenResponse.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.model; 2 | 3 | public class AccessTokenResponse { 4 | public String accessToken; 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/model/track/ExternalIds.java: -------------------------------------------------------------------------------- 1 | 2 | package de.labystudio.spotifyapi.open.model.track; 3 | 4 | public class ExternalIds { 5 | 6 | public String isrc; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/model/track/ExternalUrls.java: -------------------------------------------------------------------------------- 1 | 2 | package de.labystudio.spotifyapi.open.model.track; 3 | 4 | public class ExternalUrls { 5 | 6 | public String spotify; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/model/track/Image.java: -------------------------------------------------------------------------------- 1 | 2 | package de.labystudio.spotifyapi.open.model.track; 3 | 4 | public class Image { 5 | 6 | public Integer height; 7 | public String url; 8 | public Integer width; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/resources/secrets.json: -------------------------------------------------------------------------------- 1 | { 2 | "validUntil": "2025-07-07T09:00:00.000Z", 3 | "secrets": [{ 4 | "secret": "=n:b#OuEfH\\fE])e*K", 5 | "version": 10 6 | }, { 7 | "secret": "meZcB\\tlUFV1D6W2Hy4@9+$QaH5)N8", 8 | "version": 9 9 | }, { 10 | "secret": [37, 84, 32, 76, 87, 90, 87, 47, 13, 75, 48, 54, 44, 28, 19, 21, 22], 11 | "version": 8 12 | }] 13 | } -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/model/track/Artist.java: -------------------------------------------------------------------------------- 1 | 2 | package de.labystudio.spotifyapi.open.model.track; 3 | 4 | import com.google.gson.annotations.SerializedName; 5 | 6 | public class Artist { 7 | 8 | @SerializedName("external_urls") 9 | public ExternalUrls externalUrls; 10 | public String href; 11 | public String id; 12 | public String name; 13 | public String type; 14 | public String uri; 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/linux/api/model/InterfaceMember.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.linux.api.model; 2 | 3 | /** 4 | * Interface member wrapper for the DBusSend class. 5 | * 6 | * @author LabyStudio 7 | */ 8 | public class InterfaceMember { 9 | 10 | private final String path; 11 | 12 | public InterfaceMember(String path) { 13 | this.path = path; 14 | } 15 | 16 | public String toString() { 17 | return this.path; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "windowsmediacontrol" 3 | version = "1.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | jni = "0.21.1" 8 | windows = { version = "0.61.3", features = ["Media_Control", "Win32_Foundation", "Storage_Streams"] } 9 | futures = "0.3" 10 | lazy_static = "1.5.0" 11 | libc = "1.0.0-alpha.1" 12 | 13 | [lib] 14 | crate-type = ["cdylib"] 15 | 16 | [[bin]] 17 | name = "windowsmediacontrol_bin" 18 | path = "src/main.rs" 19 | 20 | [profile.release] 21 | lto = true 22 | codegen-units = 1 23 | opt-level = "s" 24 | panic = "abort" 25 | strip = "symbols" 26 | debug = false 27 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/totp/provider/DefaultSecretProvider.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.totp.provider; 2 | 3 | import de.labystudio.spotifyapi.open.totp.model.Secret; 4 | 5 | /** 6 | * Default implementation to provide a single secret for TOTP generation. 7 | * 8 | * @author LabyStudio 9 | */ 10 | public class DefaultSecretProvider implements SecretProvider { 11 | 12 | private final Secret secret; 13 | 14 | public DefaultSecretProvider(Secret secret) { 15 | this.secret = secret; 16 | } 17 | 18 | @Override 19 | public Secret getSecret() { 20 | return this.secret; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/totp/provider/SecretProvider.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.totp.provider; 2 | 3 | import de.labystudio.spotifyapi.open.totp.model.Secret; 4 | 5 | import java.io.IOException; 6 | 7 | /** 8 | * This interface is used to provide a secret for TOTP generation. 9 | * It must be implemented to retrieve the latest secret from open.spotify.com 10 | * 11 | * @author LabyStudio 12 | */ 13 | public interface SecretProvider { 14 | 15 | /** 16 | * Retrieves the latest secret from open.spotify.com used for TOTP generation. 17 | * 18 | * @return The latest TOTP secret used for generating time-based one-time passwords. 19 | */ 20 | Secret getSecret() throws IOException; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/SpotifyListenerAdapter.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi; 2 | 3 | import de.labystudio.spotifyapi.model.Track; 4 | 5 | public class SpotifyListenerAdapter implements SpotifyListener { 6 | 7 | @Override 8 | public void onConnect() { 9 | 10 | } 11 | 12 | @Override 13 | public void onTrackChanged(Track track) { 14 | 15 | } 16 | 17 | @Override 18 | public void onPositionChanged(int position) { 19 | 20 | } 21 | 22 | @Override 23 | public void onPlayBackChanged(boolean isPlaying) { 24 | 25 | } 26 | 27 | @Override 28 | public void onSync() { 29 | 30 | } 31 | 32 | @Override 33 | public void onDisconnect(Exception exception) { 34 | 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Gradle build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | packages: write 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up JDK 16 19 | uses: actions/setup-java@v1 20 | with: 21 | java-version: '16' 22 | - name: Grant execute permission for gradlew 23 | run: chmod +x gradlew 24 | - name: Build with Gradle 25 | run: ./gradlew build 26 | - name: Upload jar 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: Artifacts 30 | path: build/libs/*.jar -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/playback/PlaybackAccessor.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.playback; 2 | 3 | public interface PlaybackAccessor { 4 | 5 | void updatePlayback(); 6 | 7 | void updateTrack(); 8 | 9 | boolean isValid(); 10 | 11 | int getLength(); 12 | 13 | int getPosition(); 14 | 15 | boolean isPlaying(); 16 | 17 | String getTitle(); 18 | 19 | String getArtist(); 20 | 21 | byte[] getCoverArt(); 22 | 23 | default boolean hasTrackLength() { 24 | return this.getLength() > 0; 25 | } 26 | 27 | default boolean hasTrackPosition() { 28 | return this.getPosition() >= 0 && this.hasTrackLength() && this.getPosition() <= this.getLength(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/platform/SpotifyDirectTest.java: -------------------------------------------------------------------------------- 1 | package platform; 2 | 3 | import de.labystudio.spotifyapi.SpotifyAPI; 4 | import de.labystudio.spotifyapi.SpotifyAPIFactory; 5 | 6 | public class SpotifyDirectTest { 7 | 8 | public static void main(String[] args) throws Exception { 9 | SpotifyAPI api = SpotifyAPIFactory.createInitialized(); 10 | 11 | // It has no track until the song started playing once 12 | if (api.hasTrack()) { 13 | System.out.println("Current playing track: " + api.getTrack()); 14 | } 15 | 16 | // It has no position until the song is paused, the position changed or the song changed 17 | if (api.hasPosition()) { 18 | System.out.println("Current track position: " + api.getPosition()); 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/totp/gson/SecretSerializer.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.totp.gson; 2 | 3 | import com.google.gson.JsonElement; 4 | import com.google.gson.JsonObject; 5 | import com.google.gson.JsonSerializationContext; 6 | import com.google.gson.JsonSerializer; 7 | import de.labystudio.spotifyapi.open.totp.model.Secret; 8 | 9 | import java.lang.reflect.Type; 10 | 11 | /** 12 | * This class is used to serialize the TOTP secret to Spotify's TOTP storage format. 13 | * 14 | * @author LabyStudio 15 | */ 16 | public class SecretSerializer implements JsonSerializer { 17 | 18 | @Override 19 | public JsonElement serialize(Secret secret, Type type, JsonSerializationContext context) { 20 | JsonObject obj = new JsonObject(); 21 | obj.addProperty("version", secret.getVersion()); 22 | obj.addProperty("secret", secret.getSecretAsString()); 23 | return obj; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/OpenSpotifyApiTest.java: -------------------------------------------------------------------------------- 1 | import de.labystudio.spotifyapi.open.OpenSpotifyAPI; 2 | import de.labystudio.spotifyapi.open.model.track.OpenTrack; 3 | import de.labystudio.spotifyapi.open.totp.model.Secret; 4 | import de.labystudio.spotifyapi.open.totp.provider.DefaultSecretProvider; 5 | import de.labystudio.spotifyapi.open.totp.provider.SecretProvider; 6 | 7 | public class OpenSpotifyApiTest { 8 | public static void main(String[] args) throws Exception { 9 | SecretProvider secretProvider = new DefaultSecretProvider( 10 | // Note: You have to update the secret with the latest TOTP secret from open.spotify.com 11 | Secret.fromString("=n:b#OuEfH\\fE])e*K", 10) 12 | ); 13 | OpenSpotifyAPI openSpotifyAPI = new OpenSpotifyAPI(secretProvider); 14 | OpenTrack openTrack = openSpotifyAPI.requestOpenTrack("38T0tPVZHcPZyhtOcCP7pF"); 15 | System.out.println(openTrack.name); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/model/track/Album.java: -------------------------------------------------------------------------------- 1 | 2 | package de.labystudio.spotifyapi.open.model.track; 3 | 4 | import com.google.gson.annotations.SerializedName; 5 | 6 | import java.util.List; 7 | 8 | public class Album { 9 | 10 | @SerializedName("album_type") 11 | public String albumType; 12 | 13 | public List artists = null; 14 | 15 | @SerializedName("available_markets") 16 | public List availableMarkets = null; 17 | 18 | @SerializedName("external_urls") 19 | public ExternalUrls externalUrls; 20 | 21 | public String href; 22 | public String id; 23 | public List images = null; 24 | public String name; 25 | 26 | @SerializedName("release_date") 27 | public String releaseDate; 28 | 29 | @SerializedName("release_date_precision") 30 | public String releaseDatePrecision; 31 | 32 | @SerializedName("total_tracks") 33 | public Integer totalTracks; 34 | 35 | public String type; 36 | public String uri; 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/linux/api/model/Parameter.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.linux.api.model; 2 | 3 | /** 4 | * Parameter wrapper for the DBusSend class. 5 | *

6 | * It appends the parameter key and value to the command. 7 | * If the value is null, it will only append the key using "--key". 8 | * If the value is not null, it will append the key and value using "--key=value". 9 | * 10 | * @author LabyStudio 11 | */ 12 | public class Parameter { 13 | 14 | private final String key; 15 | private final String value; 16 | 17 | public Parameter(String key, String value) { 18 | this.key = key; 19 | this.value = value; 20 | } 21 | 22 | public Parameter(String key) { 23 | this(key, null); 24 | } 25 | 26 | public String toString() { 27 | return String.format( 28 | "--%s%s", 29 | this.key, 30 | this.value == null ? "" : "=" + this.value 31 | ); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/jna/WindowsMediaControl.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.jna; 2 | 3 | import com.sun.jna.Library; 4 | import com.sun.jna.Native; 5 | import com.sun.jna.Pointer; 6 | import com.sun.jna.ptr.NativeLongByReference; 7 | import com.sun.jna.ptr.PointerByReference; 8 | 9 | import java.nio.file.Path; 10 | 11 | public interface WindowsMediaControl extends Library { 12 | 13 | boolean isSpotifyAvailable(); 14 | 15 | long getPlaybackPosition(); 16 | 17 | long getTrackDuration(); 18 | 19 | Pointer getTrackTitle(); 20 | 21 | Pointer getArtistName(); 22 | 23 | int isPlaying(); 24 | 25 | boolean getCoverArt(PointerByReference outPtr, NativeLongByReference outLen); 26 | 27 | void freeString(Pointer str); 28 | 29 | void freeCoverArt(Pointer ptr); 30 | 31 | static WindowsMediaControl loadLibrary(Path dllPath) { 32 | return Native.load(dllPath.toAbsolutePath().toString(), WindowsMediaControl.class); 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/jna/Kernel32.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.jna; 2 | 3 | import com.sun.jna.Native; 4 | import com.sun.jna.Pointer; 5 | import com.sun.jna.platform.win32.WinNT; 6 | import com.sun.jna.ptr.IntByReference; 7 | import com.sun.jna.win32.StdCallLibrary; 8 | import com.sun.jna.win32.W32APIOptions; 9 | 10 | public interface Kernel32 extends WinNT, StdCallLibrary { 11 | 12 | Kernel32 INSTANCE = (Kernel32) Native.loadLibrary("kernel32", Kernel32.class, W32APIOptions.UNICODE_OPTIONS); 13 | 14 | boolean ReadProcessMemory(HANDLE hProcess, Pointer lpBaseAddress, Pointer lpBuffer, int nSize, IntByReference lpNumberOfBytesRead); 15 | 16 | boolean Module32NextW(HANDLE hSnapshot, Tlhelp32.MODULEENTRY32W lpme); 17 | 18 | boolean Process32First(HANDLE var1, Tlhelp32.PROCESSENTRY32.ByReference var2); 19 | 20 | boolean Process32Next(HANDLE var1, Tlhelp32.PROCESSENTRY32.ByReference var2); 21 | 22 | HANDLE CreateToolhelp32Snapshot(DWORD dwFlags, DWORD th32ProcessID); 23 | 24 | boolean CloseHandle(HANDLE hObject); 25 | 26 | HANDLE OpenProcess(int fdwAccess, boolean fInherit, int IDProcess); 27 | 28 | int GetLastError(); 29 | } -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/osx/api/Action.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.osx.api; 2 | 3 | 4 | /** 5 | * Wrapper for the AppleScript parameters. 6 | * 7 | * @author LabyStudio 8 | */ 9 | public class Action { 10 | 11 | public static final Action GET = new Action("get", "the"); 12 | public static final Action OF = new Action("of"); 13 | 14 | private final String action; 15 | 16 | public Action(String... args) { 17 | this.action = String.join(" ", args); 18 | } 19 | 20 | public Action(String argument) { 21 | this.action = argument; 22 | } 23 | 24 | public Action(Action... actions) { 25 | this.action = toString(actions); 26 | } 27 | 28 | @Override 29 | public String toString() { 30 | return this.action; 31 | } 32 | 33 | /** 34 | * Convert an array of actions to a string. 35 | * 36 | * @param actions The actions to convert 37 | * @return The string representation of the actions 38 | */ 39 | public static String toString(Action... actions) { 40 | String[] args = new String[actions.length]; 41 | for (int i = 0; i < actions.length; i++) { 42 | args[i] = actions[i].toString(); 43 | } 44 | return String.join(" ", args); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/linux/api/model/Metadata.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.linux.api.model; 2 | 3 | import java.util.Map; 4 | 5 | public class Metadata { 6 | 7 | private final String trackId; 8 | private final String trackName; 9 | private final String[] artists; 10 | private final int trackLength; 11 | private final String artUrl; 12 | 13 | public Metadata(Map metadata) { 14 | this.trackId = ((String) metadata.get("mpris:trackid")).split("/")[4]; 15 | this.trackName = metadata.get("xesam:title").toString(); 16 | this.artists = (String[]) metadata.get("xesam:artist"); 17 | this.trackLength = (int) ((Long) metadata.get("mpris:length") / 1000L) + 1; 18 | this.artUrl = (String) metadata.get("mpris:artUrl"); 19 | } 20 | 21 | public String getTrackId() { 22 | return this.trackId; 23 | } 24 | 25 | public String getTrackName() { 26 | return this.trackName; 27 | } 28 | 29 | public String[] getArtists() { 30 | return this.artists; 31 | } 32 | 33 | public String getArtistsJoined() { 34 | return String.join(", ", this.artists); 35 | } 36 | 37 | public int getTrackLength() { 38 | return this.trackLength; 39 | } 40 | 41 | public String getArtUrl() { 42 | return this.artUrl; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/totp/model/SecretStorage.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.totp.model; 2 | 3 | /** 4 | * Storage format for all TOTP secrets used by Spotify. 5 | * This storage format is used on open.spotify.com 6 | * 7 | * @author LabyStudio 8 | */ 9 | public class SecretStorage { 10 | 11 | private String validUntil; 12 | private Secret[] secrets; 13 | 14 | public String getValidUntil() { 15 | return this.validUntil; 16 | } 17 | 18 | public Secret[] getSecrets() { 19 | return this.secrets; 20 | } 21 | 22 | /** 23 | * Retrieves the latest secret from the storage. 24 | * This method iterates through all stored secrets and returns the one with the highest version number. 25 | * If no secrets are available, it returns null. 26 | * 27 | * @return The latest TOTP secret with the highest version number, or null if no secrets are available. 28 | */ 29 | public Secret getLatestSecret() { 30 | if (this.secrets == null || this.secrets.length == 0) { 31 | return null; 32 | } 33 | Secret latestSecret = this.secrets[0]; 34 | for (Secret secret : this.secrets) { 35 | if (secret.getVersion() > latestSecret.getVersion()) { 36 | latestSecret = secret; 37 | } 38 | } 39 | return latestSecret; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/totp/gson/SecretDeserializer.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.totp.gson; 2 | 3 | import com.google.gson.*; 4 | import de.labystudio.spotifyapi.open.totp.model.Secret; 5 | 6 | import java.lang.reflect.Type; 7 | 8 | /** 9 | * This class is used to deserialize the TOTP secret from Spotify's TOTP storage format. 10 | * 11 | * @author LabyStudio 12 | */ 13 | public class SecretDeserializer implements JsonDeserializer { 14 | 15 | @Override 16 | public Secret deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { 17 | JsonObject obj = json.getAsJsonObject(); 18 | 19 | int version = obj.get("version").getAsInt(); 20 | JsonElement secret = obj.get("secret"); 21 | 22 | // Check if the secret is an integer array 23 | if (secret.isJsonArray()) { 24 | JsonArray array = secret.getAsJsonArray(); 25 | int[] numbers = new int[array.size()]; 26 | for (int i = 0; i < array.size(); i++) { 27 | numbers[i] = array.get(i).getAsInt(); 28 | } 29 | return Secret.fromNumbers(numbers, version); 30 | } 31 | 32 | // Check if the secret is a string 33 | if (secret.isJsonPrimitive() && secret.getAsJsonPrimitive().isString()) { 34 | String secretString = secret.getAsString(); 35 | return Secret.fromString(secretString, version); 36 | } 37 | 38 | throw new JsonParseException("Invalid secret format: " + secret); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/SpotifyListener.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi; 2 | 3 | import de.labystudio.spotifyapi.model.Track; 4 | 5 | /** 6 | * Interface to receive playback updates from Spotify. 7 | * 8 | * @author LabyStudio 9 | */ 10 | public interface SpotifyListener { 11 | 12 | /** 13 | * Called when the api successfully connected to the Spotify application. 14 | */ 15 | void onConnect(); 16 | 17 | /** 18 | * Called when the id of the current playing track changed. 19 | * 20 | * @param track the new track 21 | */ 22 | void onTrackChanged(Track track); 23 | 24 | /** 25 | * This event is only called when the user jumps to another position during the song, 26 | * when the playback state changed or when the track changed. 27 | * To keep track of the actual interpolated position, use {@link SpotifyAPI#getPosition()}. 28 | * 29 | * @param position the new position in milliseconds 30 | */ 31 | void onPositionChanged(int position); 32 | 33 | /** 34 | * Called when the playback state changed. 35 | * 36 | * @param isPlaying true if the playback is playing, false if it is paused 37 | */ 38 | void onPlayBackChanged(boolean isPlaying); 39 | 40 | /** 41 | * Called when the api successfully fetched the latest data from Spotify. 42 | */ 43 | void onSync(); 44 | 45 | /** 46 | * Called when the api failed to fetch data from Spotify. 47 | * The api will immediately try to reconnect to Spotify. 48 | * 49 | * @param exception the exception that occurred 50 | */ 51 | void onDisconnect(Exception exception); 52 | } 53 | -------------------------------------------------------------------------------- /src/test/resources/dbus/metadata.variant: -------------------------------------------------------------------------------- 1 | variant array [ 2 | dict entry( 3 | string "mpris:trackid" 4 | variant string "/com/spotify/track/0r1kH7SIkkPP9W7mUknObF" 5 | ) 6 | dict entry( 7 | string "mpris:length" 8 | variant uint64 172000000 9 | ) 10 | dict entry( 11 | string "mpris:artUrl" 12 | variant string "https://i.scdn.co/image/ab67616d0000b27397c097afa44e5cdb38a03d4f" 13 | ) 14 | dict entry( 15 | string "xesam:album" 16 | variant string "Raop" 17 | ) 18 | dict entry( 19 | string "xesam:albumArtist" 20 | variant array [ 21 | string "CRO" 22 | ] 23 | ) 24 | dict entry( 25 | string "xesam:artist" 26 | variant array [ 27 | string "CRO" 28 | ] 29 | ) 30 | dict entry( 31 | string "xesam:autoRating" 32 | variant double 0.01 33 | ) 34 | dict entry( 35 | string "xesam:discNumber" 36 | variant int32 1 37 | ) 38 | dict entry( 39 | string "xesam:title" 40 | variant string "Easy" 41 | ) 42 | dict entry( 43 | string "xesam:trackNumber" 44 | variant int32 3 45 | ) 46 | dict entry( 47 | string "xesam:url" 48 | variant string "https://open.spotify.com/track/0r1kH7SIkkPP9W7mUknObF" 49 | ) 50 | ] -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/jna/Psapi.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.jna; 2 | 3 | import com.sun.jna.Native; 4 | import com.sun.jna.Pointer; 5 | import com.sun.jna.Structure; 6 | import com.sun.jna.platform.win32.WinNT; 7 | import com.sun.jna.ptr.IntByReference; 8 | import com.sun.jna.win32.StdCallLibrary; 9 | import com.sun.jna.win32.W32APIOptions; 10 | 11 | import java.util.ArrayList; 12 | import java.util.Arrays; 13 | import java.util.List; 14 | 15 | public interface Psapi extends WinNT, StdCallLibrary { 16 | 17 | Psapi INSTANCE = Native.loadLibrary("psapi", Psapi.class, W32APIOptions.UNICODE_OPTIONS); 18 | 19 | boolean EnumProcessModulesEx(HANDLE hProcess, Pointer[] lphModule, int cb, IntByReference lpcbNeeded, int dwFilterFlag); 20 | 21 | int GetModuleBaseName(HANDLE hProcess, Pointer hModule, char[] lpBaseName, int nSize); 22 | 23 | boolean GetModuleInformation(HANDLE hProcess, Pointer hModule, ModuleInfo moduleInfo, int size); 24 | 25 | class ModuleFilter { 26 | public static final int NONE = 0x00; 27 | public static final int X32BIT = 0x01; 28 | public static final int X64BIT = 0x02; 29 | public static final int ALL = 0x03; 30 | } 31 | 32 | class ModuleInfo extends Structure { 33 | 34 | public Pointer BaseOfDll; 35 | public int SizeOfImage; 36 | public Pointer EntryPoint; 37 | 38 | public long getBaseOfDll() { 39 | return Pointer.nativeValue(this.BaseOfDll); 40 | } 41 | 42 | public long getEntryPoint() { 43 | return Pointer.nativeValue(this.EntryPoint); 44 | } 45 | 46 | public int getSizeOfImage() { 47 | return this.SizeOfImage; 48 | } 49 | 50 | @Override 51 | protected List getFieldOrder() { 52 | return new ArrayList<>(Arrays.asList("BaseOfDll", "SizeOfImage", "EntryPoint")); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /rust/src/session.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use std::sync::Mutex; 3 | use std::time::{Duration, Instant}; 4 | use windows::core::Result; 5 | use windows::Media::Control::*; 6 | 7 | struct CachedSession { 8 | session: GlobalSystemMediaTransportControlsSession, 9 | timestamp: Instant, 10 | } 11 | 12 | lazy_static! { 13 | static ref SPOTIFY_SESSION_CACHE: Mutex> = Mutex::new(None); 14 | } 15 | 16 | const CACHE_TTL: Duration = Duration::from_secs(3); 17 | 18 | pub fn is_spotify(session: &GlobalSystemMediaTransportControlsSession) -> Result { 19 | let app_id = session.SourceAppUserModelId()?; 20 | let app_id = app_id.to_string(); 21 | 22 | Ok( 23 | app_id == "Spotify.exe" 24 | || (app_id.starts_with("SpotifyAB") && app_id.ends_with("!Spotify")), 25 | ) 26 | } 27 | 28 | pub fn get_spotify_session() -> Result> { 29 | let mut cache = SPOTIFY_SESSION_CACHE.lock().unwrap(); 30 | 31 | // Check cache expiration and validity 32 | if let Some(cached) = cache.as_ref() { 33 | if cached.timestamp.elapsed() < CACHE_TTL && is_spotify(&cached.session)? { 34 | return Ok(Some(cached.session.clone())); 35 | } 36 | // Cache expired or invalid, drop it 37 | *cache = None; 38 | } 39 | 40 | // Refresh the session cache 41 | let manager_operation = GlobalSystemMediaTransportControlsSessionManager::RequestAsync()?; 42 | let manager = manager_operation.get()?; 43 | let sessions = manager.GetSessions()?; 44 | 45 | for session in sessions { 46 | if is_spotify(&session)? { 47 | // Cache new session with current timestamp 48 | *cache = Some(CachedSession { 49 | session: session.clone(), 50 | timestamp: Instant::now(), 51 | }); 52 | return Ok(Some(session)); 53 | } 54 | } 55 | 56 | // No valid session found 57 | *cache = None; 58 | Ok(None) 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/spotify/SpotifyWindowTitle.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.spotify; 2 | 3 | /** 4 | * This class represents the title of the Spotify application. 5 | * It contains the currently played track name and track artist if the song is playing. 6 | *

7 | * If the song is not playing, the track name and artist are unknown. 8 | * It uses {@link #UNKNOWN} for the unknown track name and artist. 9 | * 10 | * @author LabyStudio 11 | */ 12 | public class SpotifyWindowTitle { 13 | 14 | /** 15 | * The delimiter used by the title to separate the track name and artist. 16 | */ 17 | public static final String DELIMITER = " - "; 18 | 19 | /** 20 | * The unknown track name and artist. 21 | * Required if the song is paused or no track is playing. 22 | */ 23 | public static final SpotifyWindowTitle UNKNOWN = new SpotifyWindowTitle("Unknown", "No song playing"); 24 | 25 | private final String name; 26 | private final String artist; 27 | 28 | public SpotifyWindowTitle(String name, String artist) { 29 | this.name = name; 30 | this.artist = artist; 31 | } 32 | 33 | public String getTrackName() { 34 | return this.name; 35 | } 36 | 37 | public String getTrackArtist() { 38 | return this.artist; 39 | } 40 | 41 | @Override 42 | public String toString() { 43 | return this.name + DELIMITER + this.artist; 44 | } 45 | 46 | /** 47 | * Create a SpotifyTitle from a title bar string. 48 | * It splits the title bar string into the track name and artist using the {@link #DELIMITER}. 49 | * 50 | * @param title the title bar string 51 | * @return the SpotifyTitle 52 | */ 53 | public static SpotifyWindowTitle of(String title) { 54 | if (!title.contains(DELIMITER)) { 55 | return null; 56 | } 57 | 58 | String[] split = title.split(DELIMITER); 59 | return new SpotifyWindowTitle(split[1], split[0]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/test/java/platform/linux/SpotifyDBusParserTest.java: -------------------------------------------------------------------------------- 1 | package platform.linux; 2 | 3 | import de.labystudio.spotifyapi.platform.linux.api.model.Metadata; 4 | import de.labystudio.spotifyapi.platform.linux.api.model.Variant; 5 | 6 | import java.io.IOException; 7 | import java.io.InputStream; 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | 11 | public class SpotifyDBusParserTest { 12 | 13 | public static void main(String[] args) throws IOException { 14 | // Test metadata variant parsing 15 | Variant response = Variant.parse(readString("/dbus/metadata.variant")); 16 | Map map = new HashMap<>(); 17 | for (Variant entry : response.getValue()) { 18 | map.put(entry.getSig(), entry.getValue()); 19 | } 20 | Metadata metadata = new Metadata(map); 21 | if (!metadata.getTrackId().equals("0r1kH7SIkkPP9W7mUknObF")) { 22 | throw new IllegalStateException("Invalid track ID: " + metadata.getTrackId()); 23 | } 24 | 25 | // Test playing variant parsing 26 | Variant response2 = Variant.parse(readString("/dbus/playing.variant")); 27 | if (!response2.getSig().equals("variant")) { 28 | throw new IllegalStateException("Invalid sig key: " + response2.getSig()); 29 | } 30 | if (!response2.getValue().equals("Playing")) { 31 | throw new IllegalStateException("Invalid value: " + response2.getValue()); 32 | } 33 | } 34 | 35 | private static String readString(String path) throws IOException { 36 | InputStream stream = SpotifyDBusParserTest.class.getResourceAsStream(path); 37 | if (stream == null) { 38 | throw new IOException("Resource not found: " + path); 39 | } 40 | StringBuilder builder = new StringBuilder(); 41 | int character; 42 | while ((character = stream.read()) != -1) { 43 | builder.append((char) character); 44 | } 45 | stream.close(); 46 | return builder.toString(); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/model/track/OpenTrack.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.model.track; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | 5 | import java.util.List; 6 | 7 | public class OpenTrack { 8 | 9 | public Album album; 10 | 11 | public List artists = null; 12 | 13 | @SerializedName("available_markets") 14 | public List availableMarkets = null; 15 | 16 | @SerializedName("disc_number") 17 | public Integer discNumber; 18 | 19 | @SerializedName("duration_ms") 20 | public Integer durationMs; 21 | 22 | public Boolean explicit; 23 | 24 | @SerializedName("external_ids") 25 | public ExternalIds externalIds; 26 | 27 | @SerializedName("external_urls") 28 | public ExternalUrls externalUrls; 29 | 30 | public String href; 31 | 32 | public String id; 33 | 34 | @SerializedName("is_local") 35 | public Boolean isLocal; 36 | 37 | public String name; 38 | 39 | public Integer popularity; 40 | 41 | @SerializedName("preview_url") 42 | public Object previewUrl; 43 | 44 | @SerializedName("track_number") 45 | public Integer trackNumber; 46 | 47 | public String type; 48 | 49 | public String uri; 50 | 51 | private transient String joinedArtists; 52 | 53 | /** 54 | * Joins the artists to a single string. 55 | * 56 | * @return the artists name, split with comma 57 | */ 58 | public String getArtists() { 59 | if (this.joinedArtists == null) { 60 | this.joinedArtists = this.getArtists(", "); 61 | } 62 | 63 | return this.joinedArtists; 64 | } 65 | 66 | /** 67 | * Joins the artists to a single string. 68 | * 69 | * @param delimiter The delimiter to split the artists with 70 | * @return the artists name, split the provided delimiter 71 | */ 72 | public String getArtists(String delimiter) { 73 | if (this.artists == null || this.artists.isEmpty()) { 74 | return null; 75 | } 76 | 77 | StringBuilder builder = new StringBuilder(); 78 | for (Artist artist : this.artists) { 79 | builder.append(delimiter); 80 | builder.append(artist.name); 81 | } 82 | 83 | return builder.substring(delimiter.length()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/test/java/platform/windows/SpotifyProcessTest.java: -------------------------------------------------------------------------------- 1 | package platform.windows; 2 | 3 | import de.labystudio.spotifyapi.platform.windows.api.WinProcess; 4 | import de.labystudio.spotifyapi.platform.windows.api.jna.Psapi; 5 | 6 | import java.util.ArrayList; 7 | import java.util.Comparator; 8 | import java.util.List; 9 | import java.util.Map; 10 | 11 | public class SpotifyProcessTest { 12 | 13 | public static void main(String[] args) { 14 | WinProcess process = new WinProcess("Spotify.exe"); 15 | Psapi.ModuleInfo moduleInfo = process.getModuleInfo("chrome_elf.dll"); 16 | System.out.println("chrome_elf.dll address: 0x" + Long.toHexString(moduleInfo.getBaseOfDll())); 17 | 18 | long addressTrackId = process.findAddressOfText(moduleInfo.getBaseOfDll(), "spotify:track:", 0); 19 | System.out.println("Track Id Address: 0x" + Long.toHexString(addressTrackId)); 20 | } 21 | 22 | public static void printModules(WinProcess process, long targetAddress) { 23 | List modules = new ArrayList<>(); 24 | for (Map.Entry entry : process.getModules().entrySet()) { 25 | modules.add(new Module(entry.getKey(), entry.getValue().getBaseOfDll())); 26 | } 27 | modules.sort(Comparator.comparingLong(o -> o.address)); 28 | 29 | int passed = 0; 30 | for (Module entry : modules) { 31 | long entryPoint = entry.address; 32 | if (entryPoint > targetAddress) { 33 | if (passed == 0) { 34 | System.out.println(Long.toHexString(targetAddress) + " <-TARGET ------------------------"); 35 | } 36 | passed++; 37 | if (passed > 1) { 38 | break; 39 | } 40 | } 41 | System.out.println(Long.toHexString(entryPoint) + " " + entry.name); 42 | } 43 | } 44 | 45 | private static class Module { 46 | private final String name; 47 | private final long address; 48 | 49 | public Module(String name, long address) { 50 | this.name = name; 51 | this.address = address; 52 | } 53 | 54 | public String getName() { 55 | return this.name; 56 | } 57 | 58 | public long getAddress() { 59 | return this.address; 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/test/java/platform/windows/WindowMediaSessionTest.java: -------------------------------------------------------------------------------- 1 | package platform.windows; 2 | 3 | import com.sun.jna.Pointer; 4 | import com.sun.jna.ptr.NativeLongByReference; 5 | import com.sun.jna.ptr.PointerByReference; 6 | import de.labystudio.spotifyapi.platform.windows.api.jna.WindowsMediaControl; 7 | 8 | import javax.imageio.ImageIO; 9 | import java.awt.image.BufferedImage; 10 | import java.io.ByteArrayInputStream; 11 | import java.io.IOException; 12 | import java.nio.file.Path; 13 | import java.nio.file.Paths; 14 | 15 | public class WindowMediaSessionTest { 16 | 17 | public static void main(String[] args) throws IOException { 18 | Path dllPath = Paths.get("src/main/resources/natives/windows-x64/windowsmediacontrol.dll"); 19 | WindowsMediaControl media = WindowsMediaControl.loadLibrary(dllPath); 20 | 21 | if (!media.isSpotifyAvailable()) { 22 | System.out.println("Spotify is not available."); 23 | return; 24 | } 25 | 26 | System.out.println("Playback Position: " + media.getPlaybackPosition()); 27 | System.out.println("Track Duration: " + media.getTrackDuration()); 28 | System.out.println("Is Playing: " + media.isPlaying()); 29 | 30 | Pointer trackTitle = media.getTrackTitle(); 31 | Pointer artistName = media.getArtistName(); 32 | 33 | System.out.println("Track Title: " + (trackTitle == null ? null : trackTitle.getString(0, "UTF-8"))); 34 | System.out.println("Artist Name: " + (trackTitle == null ? null : artistName.getString(0, "UTF-8"))); 35 | 36 | media.freeString(trackTitle); 37 | media.freeString(artistName); 38 | 39 | PointerByReference bufferRef = new PointerByReference(); 40 | NativeLongByReference lengthRef = new NativeLongByReference(); 41 | 42 | if (media.getCoverArt(bufferRef, lengthRef)) { 43 | Pointer buffer = bufferRef.getValue(); 44 | int length = lengthRef.getValue().intValue(); 45 | byte[] coverArtBytes = buffer.getByteArray(0, length); 46 | BufferedImage coverArt = ImageIO.read(new ByteArrayInputStream(coverArtBytes)); 47 | 48 | System.out.println("Cover: " + coverArt.getWidth() + "x" + coverArt.getHeight()); 49 | 50 | media.freeCoverArt(buffer); 51 | } else { 52 | System.out.println("Cover: unavailable"); 53 | } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/playback/source/LegacyPlaybackAccessor.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.playback.source; 2 | 3 | import de.labystudio.spotifyapi.platform.windows.api.playback.PlaybackAccessor; 4 | import de.labystudio.spotifyapi.platform.windows.api.spotify.SpotifyProcess; 5 | import de.labystudio.spotifyapi.platform.windows.api.spotify.SpotifyWindowTitle; 6 | 7 | /** 8 | * Accessor to read the playback state from the Spotify process. 9 | * It uses a pseudo method to determine if Spotify is playing based on the window title. 10 | * 11 | * @author LabyStudio 12 | * @deprecated Scheduled for removal in future versions since we have a more reliable method using the Windows Media Control API. 13 | */ 14 | public class LegacyPlaybackAccessor implements PlaybackAccessor { 15 | 16 | private final SpotifyProcess spotifyProcess; 17 | 18 | private boolean playing; 19 | private SpotifyWindowTitle windowTitle = SpotifyWindowTitle.UNKNOWN; 20 | 21 | public LegacyPlaybackAccessor(SpotifyProcess spotifyProcess) { 22 | this.spotifyProcess = spotifyProcess; 23 | } 24 | 25 | @Override 26 | public void updatePlayback() { 27 | SpotifyWindowTitle windowTitle = SpotifyWindowTitle.of(this.spotifyProcess.getWindowTitle()); 28 | if (windowTitle == null) { 29 | this.playing = false; 30 | } else { 31 | this.windowTitle = windowTitle; 32 | this.playing = true; 33 | } 34 | } 35 | 36 | @Override 37 | public void updateTrack() { 38 | // Nothing to do here, as the track information is derived from the window title 39 | } 40 | 41 | @Override 42 | public boolean isValid() { 43 | return true; 44 | } 45 | 46 | @Override 47 | public int getLength() { 48 | return -1; 49 | } 50 | 51 | @Override 52 | public int getPosition() { 53 | return -1; 54 | } 55 | 56 | @Override 57 | public boolean isPlaying() { 58 | return this.playing; 59 | } 60 | 61 | @Override 62 | public String getTitle() { 63 | return this.windowTitle.getTrackName(); 64 | } 65 | 66 | @Override 67 | public String getArtist() { 68 | return this.windowTitle.getTrackArtist(); 69 | } 70 | 71 | @Override 72 | public byte[] getCoverArt() { 73 | return null; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/totp/TOTP.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.totp; 2 | 3 | import javax.crypto.Mac; 4 | import javax.crypto.spec.SecretKeySpec; 5 | import java.nio.ByteBuffer; 6 | import java.nio.ByteOrder; 7 | import java.security.InvalidKeyException; 8 | import java.security.NoSuchAlgorithmException; 9 | 10 | /** 11 | * This class is used to generate a TOTP (Time-based One-Time Password) using the given secret, time, period, and number of digits. 12 | * It uses the HMAC-SHA1 algorithm to compute the TOTP based on the provided parameters. 13 | * 14 | * @author LabyStudio 15 | */ 16 | public class TOTP { 17 | 18 | private static final String DEFAULT_ALGORITHM = "HmacSHA1"; 19 | 20 | /** 21 | * Generate a TOTP (Time-based One-Time Password) using the given secret, time, period, and number of digits. 22 | * 23 | * @param secret The secret key 24 | * @param time The time in milliseconds 25 | * @param period The period in seconds 26 | * @param digits The number of digits 27 | * @return The generated TOTP 28 | */ 29 | public static String generateOtp(byte[] secret, long time, int period, int digits) { 30 | long counter = time / period; 31 | 32 | // Convert counter to byte array (Big Endian) 33 | ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); 34 | buffer.putLong(counter); 35 | byte[] counterBytes = buffer.array(); 36 | 37 | try { 38 | Mac mac = Mac.getInstance(DEFAULT_ALGORITHM); 39 | mac.init(new SecretKeySpec(secret, DEFAULT_ALGORITHM)); 40 | byte[] hmac = mac.doFinal(counterBytes); 41 | 42 | // Extract dynamic offset 43 | int offset = hmac[hmac.length - 1] & 0x0F; 44 | 45 | // Compute binary value 46 | int binary = ((hmac[offset] & 0x7F) << 24) | 47 | ((hmac[offset + 1] & 0xFF) << 16) | 48 | ((hmac[offset + 2] & 0xFF) << 8) | 49 | (hmac[offset + 3] & 0xFF); 50 | 51 | // Compute OTP 52 | int otp = binary % ((int) Math.pow(10, digits)); 53 | 54 | // Return zero-padded OTP 55 | return String.format("%0" + digits + "d", otp); 56 | } catch (NoSuchAlgorithmException | InvalidKeyException e) { 57 | throw new IllegalStateException("Failed to generate TOTP", e); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /rust/src/main.rs: -------------------------------------------------------------------------------- 1 | use futures::executor::block_on; 2 | use windows::Storage::Streams::DataReader; 3 | use windows::{core::*, Media::Control::*}; 4 | 5 | mod session; 6 | pub use session::get_spotify_session; 7 | 8 | fn main() -> Result<()> { 9 | block_on(async { 10 | let session = get_spotify_session()?; 11 | if session.is_none() { 12 | println!("Spotify is not currently running."); 13 | return Ok(()); 14 | } 15 | 16 | let session = session.unwrap(); 17 | 18 | // Print the current playback status 19 | let playback_info = session.GetPlaybackInfo()?; 20 | let playback_status = playback_info.PlaybackStatus()?; 21 | println!( 22 | "Spotify Playback Status: {:?}", 23 | playback_status == GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing 24 | ); 25 | 26 | // Print the current track name 27 | if let Some(media_properties) = session.TryGetMediaPropertiesAsync()?.get().ok() { 28 | println!("Current Track Name: {}", media_properties.Title()?); 29 | println!("Current Artist Name: {}", media_properties.Artist()?); 30 | } else { 31 | println!("Could not retrieve media properties."); 32 | } 33 | 34 | // Get position and track length 35 | let timeline = session.GetTimelineProperties()?; 36 | println!( 37 | "Current Position: {:?}", 38 | timeline.Position()?.Duration / 10_000 39 | ); 40 | println!( 41 | "Track Duration: {:?}", 42 | timeline.EndTime()?.Duration / 10_000 43 | ); 44 | 45 | if let Some(media_properties) = session.TryGetMediaPropertiesAsync()?.get().ok() { 46 | println!("Current Track Name: {}", media_properties.Title()?); 47 | println!("Current Artist Name: {}", media_properties.Artist()?); 48 | 49 | // Get cover art thumbnail 50 | let thumbnail = media_properties.Thumbnail()?; // No Option here 51 | let stream_ref = thumbnail.OpenReadAsync()?.get()?; 52 | let size = stream_ref.Size()?; 53 | 54 | let reader = DataReader::CreateDataReader(&stream_ref)?; 55 | reader.LoadAsync(size as u32)?.get()?; 56 | let mut buffer = vec![0u8; size as usize]; 57 | reader.ReadBytes(&mut buffer)?; 58 | 59 | println!("Cover art thumbnail size: {} bytes", buffer.len()); 60 | } 61 | 62 | Ok(()) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/linux/api/MPRISCommunicator.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.linux.api; 2 | 3 | import de.labystudio.spotifyapi.platform.linux.api.model.InterfaceMember; 4 | import de.labystudio.spotifyapi.platform.linux.api.model.Metadata; 5 | import de.labystudio.spotifyapi.platform.linux.api.model.Parameter; 6 | import de.labystudio.spotifyapi.platform.linux.api.model.Variant; 7 | 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | 11 | /** 12 | * MPRIS communicator 13 | *

14 | * This class is used to communicate with the MPRIS interface. 15 | * It can be used to get the current track, track position and to control the playback. 16 | * 17 | * @author holybaechu, LabyStudio 18 | */ 19 | public class MPRISCommunicator { 20 | 21 | private static final Parameter PARAM_DEST = new Parameter("dest", "org.mpris.MediaPlayer2.spotify"); 22 | 23 | private static final InterfaceMember INTERFACE_PLAY_PAUSE = new InterfaceMember("org.mpris.MediaPlayer2.Player.PlayPause"); 24 | private static final InterfaceMember INTERFACE_NEXT = new InterfaceMember("org.mpris.MediaPlayer2.Player.Next"); 25 | private static final InterfaceMember INTERFACE_PREVIOUS = new InterfaceMember("org.mpris.MediaPlayer2.Player.Previous"); 26 | 27 | private final DBusSend dbus = new DBusSend( 28 | new Parameter[]{ 29 | PARAM_DEST 30 | }, 31 | "/org/mpris/MediaPlayer2" 32 | ); 33 | 34 | public Metadata readMetadata() throws Exception { 35 | Map metadata = new HashMap<>(); 36 | Variant array = this.dbus.get("org.mpris.MediaPlayer2.Player", "Metadata"); 37 | for (Variant entry : array.getValue()) { 38 | metadata.put(entry.getSig(), entry.getValue()); 39 | } 40 | return new Metadata(metadata); 41 | } 42 | 43 | public boolean readIsPlaying() throws Exception { 44 | return this.dbus.get("org.mpris.MediaPlayer2.Player", "PlaybackStatus").getValue().equals("Playing"); 45 | } 46 | 47 | public Integer readPosition() throws Exception { 48 | return (int) ((Long) this.dbus.get("org.mpris.MediaPlayer2.Player", "Position").getValue() / 1000L); 49 | } 50 | 51 | public void playPause() throws Exception { 52 | this.dbus.send(INTERFACE_PLAY_PAUSE); 53 | } 54 | 55 | public void next() throws Exception { 56 | this.dbus.send(INTERFACE_NEXT); 57 | } 58 | 59 | public void previous() throws Exception { 60 | this.dbus.send(INTERFACE_PREVIOUS); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/test/java/platform/SpotifyPlayPauseTest.java: -------------------------------------------------------------------------------- 1 | package platform; 2 | 3 | import de.labystudio.spotifyapi.SpotifyAPI; 4 | import de.labystudio.spotifyapi.SpotifyAPIFactory; 5 | import de.labystudio.spotifyapi.SpotifyListener; 6 | import de.labystudio.spotifyapi.model.MediaKey; 7 | import de.labystudio.spotifyapi.model.Track; 8 | 9 | import java.time.Duration; 10 | import java.util.concurrent.TimeUnit; 11 | 12 | public class SpotifyPlayPauseTest { 13 | 14 | public static void main(String[] args) { 15 | SpotifyAPI api = SpotifyAPIFactory.create(); 16 | api.registerListener(new SpotifyListener() { 17 | @Override 18 | public void onConnect() { 19 | System.out.println("Connected to Spotify!"); 20 | } 21 | 22 | @Override 23 | public void onTrackChanged(Track track) { 24 | System.out.printf("Track changed: %s (%s)\n", track, formatDuration(track.getLength())); 25 | } 26 | 27 | @Override 28 | public void onPositionChanged(int position) { 29 | if (!api.hasTrack()) { 30 | return; 31 | } 32 | 33 | int length = api.getTrack().getLength(); 34 | float percentage = 100.0F / length * position; 35 | 36 | System.out.printf( 37 | "Position changed: %s of %s (%d%%)\n", 38 | formatDuration(position), 39 | formatDuration(length), 40 | (int) percentage 41 | ); 42 | 43 | System.out.println("Triggered Play/Pause Media key"); 44 | api.pressMediaKey(MediaKey.PLAY_PAUSE); 45 | } 46 | 47 | @Override 48 | public void onPlayBackChanged(boolean isPlaying) { 49 | System.out.println(isPlaying ? "Song started playing" : "Song stopped playing"); 50 | } 51 | 52 | @Override 53 | public void onSync() { 54 | 55 | } 56 | 57 | @Override 58 | public void onDisconnect(Exception exception) { 59 | System.out.println("Disconnected: " + exception.getMessage()); 60 | 61 | // api.stop(); 62 | } 63 | }); 64 | 65 | // Initialize the API 66 | api.initialize(); 67 | } 68 | 69 | private static String formatDuration(long ms) { 70 | Duration duration = Duration.ofMillis(ms); 71 | return String.format("%sm %ss", duration.toMinutes(), duration.getSeconds() - TimeUnit.MINUTES.toSeconds(duration.toMinutes())); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/Cache.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | import java.util.Map; 6 | import java.util.concurrent.ConcurrentHashMap; 7 | 8 | /** 9 | * A cache for the open spotify api. 10 | * 11 | * @param The type of the cached object 12 | * @author LabyStudio 13 | */ 14 | public class Cache { 15 | 16 | private final Map cache = new ConcurrentHashMap<>(); 17 | private final List cacheQueue = new ArrayList<>(); 18 | private int cacheSize; 19 | 20 | /** 21 | * Create a new cache with a specific size 22 | * 23 | * @param cacheSize The size of the cache. The cache will remove the oldest entry if the size is reached. 24 | */ 25 | public Cache(int cacheSize) { 26 | this.cacheSize = cacheSize; 27 | } 28 | 29 | /** 30 | * Set the maximal amount of entries to cache. 31 | * 32 | * @param cacheSize The maximal amount of entries to cache 33 | */ 34 | public void setCacheSize(int cacheSize) { 35 | this.cacheSize = cacheSize; 36 | } 37 | 38 | /** 39 | * Store an entry in the cache 40 | * If the max cache size is reached, the oldest entry will be removed. 41 | * 42 | * @param key The key of the entry 43 | */ 44 | public void push(String key, T value) { 45 | if (key == null) { 46 | throw new IllegalArgumentException("Key cannot be null"); 47 | } 48 | if (value == null) { 49 | throw new IllegalArgumentException("Value cannot be null"); 50 | } 51 | 52 | // Remove entry from cache if cache is full 53 | if (this.cacheQueue.size() > this.cacheSize) { 54 | String urlToRemove = this.cacheQueue.remove(0); 55 | this.cache.remove(urlToRemove); 56 | } 57 | 58 | // Add new entry to cache 59 | this.cache.put(key, value); 60 | this.cacheQueue.add(key); 61 | } 62 | 63 | /** 64 | * Check if the cache contains the given key. 65 | * 66 | * @param key The key to check 67 | * @return True if the cache contains the key 68 | */ 69 | public boolean has(String key) { 70 | return this.cache.containsKey(key); 71 | } 72 | 73 | /** 74 | * Get the cached entry by the given key. 75 | * 76 | * @param key The key of the entry 77 | * @return The cached entry or null if it doesn't exist 78 | */ 79 | public T get(String key) { 80 | return this.cache.get(key); 81 | } 82 | 83 | /** 84 | * Clear the cache. 85 | */ 86 | public void clear() { 87 | this.cache.clear(); 88 | this.cacheQueue.clear(); 89 | } 90 | 91 | } -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/model/Track.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.model; 2 | 3 | import java.awt.image.BufferedImage; 4 | 5 | /** 6 | * A Spotify track containing the track id, name, artist and length of a song. 7 | * 8 | * @author LabyStudio 9 | */ 10 | public class Track { 11 | 12 | public static final int ID_LENGTH = 22; 13 | 14 | private final String id; 15 | private final String name; 16 | private final String artist; 17 | 18 | private final int length; 19 | 20 | private final BufferedImage coverArt; 21 | 22 | public Track( 23 | String id, 24 | String name, 25 | String artist, 26 | int length, 27 | BufferedImage coverArt 28 | ) { 29 | this.id = id; 30 | this.name = name; 31 | this.artist = artist; 32 | this.length = length; 33 | this.coverArt = coverArt; 34 | } 35 | 36 | public boolean isIdValid() { 37 | return isTrackIdValid(this.id); 38 | } 39 | 40 | public String getId() { 41 | return this.id; 42 | } 43 | 44 | public String getName() { 45 | return this.name; 46 | } 47 | 48 | public String getArtist() { 49 | return this.artist; 50 | } 51 | 52 | public int getLength() { 53 | return this.length; 54 | } 55 | 56 | public BufferedImage getCoverArt() { 57 | return this.coverArt; 58 | } 59 | 60 | @Override 61 | public boolean equals(Object obj) { 62 | return obj instanceof Track && this.id.equals(((Track) obj).id); 63 | } 64 | 65 | @Override 66 | public int hashCode() { 67 | return this.id.hashCode(); 68 | } 69 | 70 | @Override 71 | public String toString() { 72 | return String.format("[%s] %s - %s", this.id, this.name, this.artist); 73 | } 74 | 75 | /** 76 | * Checks if the given track ID is valid. 77 | * A track ID is valid if there are no characters with a value of zero. 78 | * It also has to be exactly 22 characters long. 79 | * 80 | * @param trackId The track ID to check. 81 | * @return True if the track ID is valid, false otherwise. 82 | */ 83 | public static boolean isTrackIdValid(String trackId) { 84 | if (trackId == null) { 85 | return false; 86 | } 87 | 88 | for (char c : trackId.toCharArray()) { 89 | boolean isValidCharacter = c >= 'a' && c <= 'z' 90 | || c >= 'A' && c <= 'Z' 91 | || c >= '0' && c <= '9'; 92 | if (!isValidCharacter) { 93 | return false; 94 | } 95 | } 96 | return !trackId.contains(" ") && trackId.length() == ID_LENGTH; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/totp/model/Secret.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open.totp.model; 2 | 3 | /** 4 | * This class represents a TOTP secret used for generating time-based one-time passwords. 5 | * It contains methods to convert the secret into a byte array and to retrieve the version of the secret. 6 | * It can be created from an array of integers or a string representation of the secret. 7 | * 8 | * @author LabyStudio 9 | */ 10 | public class Secret { 11 | 12 | private final int[] secret; 13 | private final int version; 14 | 15 | private Secret(int[] secret, int version) { 16 | this.secret = secret; 17 | this.version = version; 18 | } 19 | 20 | /** 21 | * Converts the secret into a string representation. 22 | * 23 | * @return A string representation of the secret, where each character corresponds to an integer in the secret array. 24 | */ 25 | public String getSecretAsString() { 26 | StringBuilder sb = new StringBuilder(); 27 | for (int i : this.secret) { 28 | sb.append((char) i); 29 | } 30 | return sb.toString(); 31 | } 32 | 33 | /** 34 | * Converts the secret into a byte array for TOTP generation in java. 35 | * 36 | * @return A byte array representing the secret, suitable for use in TOTP generation. 37 | */ 38 | public byte[] getSecretAsBytes() { 39 | // Convert secret numbers to xor results 40 | StringBuilder xorResults = new StringBuilder(); 41 | for (int i = 0; i < this.secret.length; i++) { 42 | int result = this.secret[i] ^ (i % 33 + 9); 43 | xorResults.append(result); 44 | } 45 | 46 | // Convert xor results to hex 47 | StringBuilder hexResult = new StringBuilder(); 48 | for (int i = 0; i < xorResults.length(); i++) { 49 | hexResult.append(String.format("%02x", (int) xorResults.charAt(i))); 50 | } 51 | 52 | // Convert hex to byte array 53 | byte[] byteArray = new byte[hexResult.length() / 2]; 54 | for (int i = 0; i < hexResult.length(); i += 2) { 55 | int byteValue = Integer.parseInt(hexResult.substring(i, i + 2), 16); 56 | byteArray[i / 2] = (byte) byteValue; 57 | } 58 | return byteArray; 59 | } 60 | 61 | public int getVersion() { 62 | return this.version; 63 | } 64 | 65 | public static Secret fromNumbers(int[] secret, int version) { 66 | return new Secret(secret, version); 67 | } 68 | 69 | public static Secret fromString(String secret, int version) { 70 | int[] array = new int[secret.length()]; 71 | for (int i = 0; i < secret.length(); i++) { 72 | array[i] = secret.charAt(i); 73 | } 74 | return new Secret(array, version); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/test/java/platform/SpotifyListenerTest.java: -------------------------------------------------------------------------------- 1 | package platform; 2 | 3 | import de.labystudio.spotifyapi.SpotifyAPI; 4 | import de.labystudio.spotifyapi.SpotifyAPIFactory; 5 | import de.labystudio.spotifyapi.SpotifyListener; 6 | import de.labystudio.spotifyapi.model.Track; 7 | 8 | import java.awt.image.BufferedImage; 9 | import java.time.Duration; 10 | import java.util.concurrent.TimeUnit; 11 | 12 | public class SpotifyListenerTest { 13 | 14 | public static void main(String[] args) { 15 | SpotifyAPI localApi = SpotifyAPIFactory.create(); 16 | localApi.registerListener(new SpotifyListener() { 17 | @Override 18 | public void onConnect() { 19 | System.out.println("Connected to Spotify!"); 20 | } 21 | 22 | @Override 23 | public void onTrackChanged(Track track) { 24 | System.out.printf("Track changed: %s (%s)\n", track, formatDuration(track.getLength())); 25 | 26 | if (track.getCoverArt() != null) { 27 | BufferedImage coverArt = track.getCoverArt(); 28 | System.out.println("Track cover: " + coverArt.getWidth() + "x" + coverArt.getHeight()); 29 | } 30 | } 31 | 32 | @Override 33 | public void onPositionChanged(int position) { 34 | if (!localApi.hasTrack()) { 35 | return; 36 | } 37 | 38 | int length = localApi.getTrack().getLength(); 39 | float percentage = 100.0F / length * position; 40 | 41 | System.out.printf( 42 | "Position changed: %s of %s (%d%%)\n", 43 | formatDuration(position), 44 | formatDuration(length), 45 | (int) percentage 46 | ); 47 | } 48 | 49 | @Override 50 | public void onPlayBackChanged(boolean isPlaying) { 51 | System.out.println(isPlaying ? "Song started playing" : "Song stopped playing"); 52 | } 53 | 54 | @Override 55 | public void onSync() { 56 | // System.out.println(formatDuration(api.getPosition())); 57 | } 58 | 59 | @Override 60 | public void onDisconnect(Exception exception) { 61 | System.out.println("Disconnected: " + exception.getMessage()); 62 | 63 | // api.stop(); 64 | } 65 | }); 66 | 67 | // Initialize the API 68 | localApi.initialize(); 69 | } 70 | 71 | private static String formatDuration(long ms) { 72 | Duration duration = Duration.ofMillis(ms); 73 | return String.format("%sm %ss", duration.toMinutes(), duration.getSeconds() - TimeUnit.MINUTES.toSeconds(duration.toMinutes())); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/config/SpotifyConfiguration.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.config; 2 | 3 | import java.nio.file.Path; 4 | import java.nio.file.Paths; 5 | 6 | /** 7 | * A configuration for the spotify api 8 | * 9 | * @author LabyStudio 10 | */ 11 | public class SpotifyConfiguration { 12 | 13 | private final long exceptionReconnectDelay; 14 | private final boolean autoReconnect; 15 | private final Path nativesDirectory; 16 | 17 | private SpotifyConfiguration( 18 | long exceptionReconnectDelay, 19 | boolean autoReconnect, 20 | Path nativesDirectory 21 | ) { 22 | this.exceptionReconnectDelay = exceptionReconnectDelay; 23 | this.autoReconnect = autoReconnect; 24 | this.nativesDirectory = nativesDirectory; 25 | } 26 | 27 | public long getExceptionReconnectDelay() { 28 | return this.exceptionReconnectDelay; 29 | } 30 | 31 | public boolean isAutoReconnect() { 32 | return this.autoReconnect; 33 | } 34 | 35 | public Path getNativesDirectory() { 36 | return this.nativesDirectory; 37 | } 38 | 39 | /** 40 | * Builder to create a new spotify configuration 41 | */ 42 | public static class Builder { 43 | 44 | private long exceptionReconnectDelay = 1000 * 10L; 45 | private boolean autoReconnect = true; 46 | private Path nativesDirectory = Paths.get(System.getProperty("java.io.tmpdir"), "spotify-api-natives"); 47 | 48 | /** 49 | * Set the delay between reconnects when an exception occurs 50 | * 51 | * @param exceptionReconnectDelay The delay in milliseconds 52 | * @return The builder instance 53 | */ 54 | public Builder exceptionReconnectDelay(long exceptionReconnectDelay) { 55 | this.exceptionReconnectDelay = exceptionReconnectDelay; 56 | return this; 57 | } 58 | 59 | /** 60 | * Set if the api should automatically reconnect when an exception occurs 61 | * 62 | * @param autoReconnect The auto reconnect state 63 | * @return The builder instance 64 | */ 65 | public Builder autoReconnect(boolean autoReconnect) { 66 | this.autoReconnect = autoReconnect; 67 | return this; 68 | } 69 | 70 | /** 71 | * All libraries that are required to run the spotify api will be extracted to this directory. 72 | * 73 | * @param nativesDirectory The directory where the native libraries will be extracted to 74 | * If null, the system temporary directory will be used. 75 | * @return The builder instance 76 | */ 77 | public Builder nativesDirectory(Path nativesDirectory) { 78 | this.nativesDirectory = nativesDirectory; 79 | return this; 80 | } 81 | 82 | public SpotifyConfiguration build() { 83 | return new SpotifyConfiguration( 84 | this.exceptionReconnectDelay, 85 | this.autoReconnect, 86 | this.nativesDirectory 87 | ); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/SpotifyAPIFactory.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi; 2 | 3 | import de.labystudio.spotifyapi.config.SpotifyConfiguration; 4 | import de.labystudio.spotifyapi.platform.linux.LinuxSpotifyApi; 5 | import de.labystudio.spotifyapi.platform.osx.OSXSpotifyApi; 6 | import de.labystudio.spotifyapi.platform.windows.WinSpotifyAPI; 7 | 8 | import java.util.Locale; 9 | import java.util.concurrent.CompletableFuture; 10 | 11 | /** 12 | * Factory class for creating SpotifyAPI instances. 13 | * 14 | * @author LabyStudio 15 | */ 16 | public class SpotifyAPIFactory { 17 | 18 | /** 19 | * Creates a new SpotifyAPI instance for the current platform. 20 | * Currently, only Windows, OSX and Linux are supported. 21 | * 22 | * @return A new SpotifyAPI instance. 23 | * @throws IllegalStateException if the current platform is not supported. 24 | */ 25 | public static SpotifyAPI create() { 26 | String os = System.getProperty("os.name").toLowerCase(Locale.ENGLISH); 27 | 28 | if (os.contains("win")) { 29 | return new WinSpotifyAPI(); 30 | } 31 | if (os.contains("mac")) { 32 | return new OSXSpotifyApi(); 33 | } 34 | if (os.contains("linux")) { 35 | return new LinuxSpotifyApi(); 36 | } 37 | 38 | throw new IllegalStateException("Unsupported OS: " + os); 39 | } 40 | 41 | /** 42 | * Create an initialized SpotifyAPI instance. 43 | * Initializing will block the current thread until the SpotifyAPI instance is ready. 44 | * It will use a default configuration. 45 | * 46 | * @return A new SpotifyAPI instance. 47 | */ 48 | public static SpotifyAPI createInitialized() { 49 | return create().initialize(); 50 | } 51 | 52 | /** 53 | * Create an initialized SpotifyAPI instance. 54 | * Initializing will block the current thread until the SpotifyAPI instance is ready. 55 | * 56 | * @param configuration The configuration for the SpotifyAPI instance. 57 | * @return A new SpotifyAPI instance. 58 | */ 59 | public static SpotifyAPI createInitialized(SpotifyConfiguration configuration) { 60 | return create().initialize(configuration); 61 | } 62 | 63 | /** 64 | * Creates a new SpotifyAPI instance for the current platform asynchronously. 65 | * Currently, only Windows and OSX are supported. 66 | * 67 | * @return A future that will contain the SpotifyAPI instance. 68 | */ 69 | public static CompletableFuture createInitializedAsync() { 70 | return CompletableFuture.supplyAsync(SpotifyAPIFactory::createInitialized); 71 | } 72 | 73 | /** 74 | * Creates a new SpotifyAPI instance for the current platform asynchronously. 75 | * Currently, only Windows and OSX are supported. 76 | * It will use a default configuration. 77 | * 78 | * @param configuration The configuration for the SpotifyAPI instance. 79 | * @return A future that will contain the SpotifyAPI instance. 80 | */ 81 | public static CompletableFuture createInitializedAsync(SpotifyConfiguration configuration) { 82 | return CompletableFuture.supplyAsync(() -> createInitialized(configuration)); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/jna/Tlhelp32.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.jna; 2 | 3 | import com.sun.jna.Pointer; 4 | import com.sun.jna.Structure; 5 | import com.sun.jna.platform.win32.BaseTSD; 6 | import com.sun.jna.platform.win32.WinDef; 7 | 8 | import java.util.ArrayList; 9 | import java.util.Arrays; 10 | import java.util.List; 11 | 12 | public interface Tlhelp32 { 13 | WinDef.DWORD TH32CS_SNAPMODULE = new WinDef.DWORD(0x00000008); 14 | WinDef.DWORD TH32CS_SNAPPROCESS = new WinDef.DWORD(0x00000002); 15 | 16 | class MODULEENTRY32W extends Structure { 17 | public static class ByReference extends MODULEENTRY32W implements Structure.ByReference { 18 | } 19 | 20 | public WinDef.DWORD dwSize; 21 | public WinDef.DWORD th32ModuleID; 22 | public WinDef.DWORD th32ProcessID; 23 | public WinDef.DWORD GlblcntUsage; 24 | public WinDef.DWORD ProccntUsage; 25 | public Pointer modBaseAddr; 26 | public WinDef.DWORD modBaseSize; 27 | public WinDef.HMODULE hModule; 28 | public char[] szModule = new char[255 + 1]; 29 | public char[] szExePath = new char[Kernel32.MAX_PATH]; 30 | 31 | public MODULEENTRY32W() { 32 | this.dwSize = new WinDef.DWORD(this.size()); 33 | } 34 | 35 | public MODULEENTRY32W(Pointer memory) { 36 | super(memory); 37 | this.read(); 38 | } 39 | 40 | @Override 41 | protected List getFieldOrder() { 42 | return new ArrayList<>(Arrays.asList("dwSize", "th32ModuleID", "th32ProcessID", "GlblcntUsage", 43 | "ProccntUsage", "modBaseAddr", "modBaseSize", "hModule", 44 | "szModule", "szExePath")); 45 | } 46 | } 47 | 48 | class PROCESSENTRY32 extends Structure { 49 | public WinDef.DWORD dwSize; 50 | public WinDef.DWORD cntUsage; 51 | public WinDef.DWORD th32ProcessID; 52 | public BaseTSD.ULONG_PTR th32DefaultHeapID; 53 | public WinDef.DWORD th32ModuleID; 54 | public WinDef.DWORD cntThreads; 55 | public WinDef.DWORD th32ParentProcessID; 56 | public WinDef.LONG pcPriClassBase; 57 | public WinDef.DWORD dwFlags; 58 | public char[] szExeFile = new char[260]; 59 | 60 | public PROCESSENTRY32() { 61 | this.dwSize = new WinDef.DWORD((long) this.size()); 62 | } 63 | 64 | public PROCESSENTRY32(Pointer memory) { 65 | super(memory); 66 | this.read(); 67 | } 68 | 69 | public static class ByReference extends com.sun.jna.platform.win32.Tlhelp32.PROCESSENTRY32 implements Structure.ByReference { 70 | public ByReference() { 71 | } 72 | 73 | public ByReference(Pointer memory) { 74 | super(memory); 75 | } 76 | 77 | @Override 78 | protected List getFieldOrder() { 79 | return new ArrayList<>(Arrays.asList("dwSize", "cntUsage", "th32ProcessID", "th32DefaultHeapID", 80 | "th32ModuleID", "cntThreads", "th32ParentProcessID", "pcPriClassBase", "dwFlags", 81 | "szExeFile")); 82 | } 83 | } 84 | 85 | @Override 86 | protected List getFieldOrder() { 87 | return new ArrayList<>(Arrays.asList("dwSize", "cntUsage", "th32ProcessID", "th32DefaultHeapID", 88 | "th32ModuleID", "cntThreads", "th32ParentProcessID", "pcPriClassBase", "dwFlags", 89 | "szExeFile")); 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/osx/api/AppleScript.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.osx.api; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.InputStreamReader; 5 | 6 | import static de.labystudio.spotifyapi.platform.osx.api.Action.GET; 7 | import static de.labystudio.spotifyapi.platform.osx.api.Action.OF; 8 | 9 | /** 10 | * Java wrapper for the AppleScript application 11 | * 12 | * @author LabyStudio 13 | */ 14 | public class AppleScript { 15 | 16 | private static final String GRAMMAR_FORMAT = "tell application \"%s\" to %s"; 17 | 18 | private final String[] runtimeParameters = new String[]{ 19 | "osascript", "-e", null 20 | }; 21 | 22 | private final String application; 23 | private final Runtime runtime; 24 | 25 | /** 26 | * Creates a new AppleScript API for a specific application 27 | * 28 | * @param application The application name to talk to 29 | */ 30 | public AppleScript(String application) { 31 | this.application = application; 32 | this.runtime = Runtime.getRuntime(); 33 | } 34 | 35 | /** 36 | * Request an information from the application 37 | * 38 | * @param request The requested type of information 39 | * @param of The category where the information belongs to 40 | * @return The requested information 41 | * @throws Exception If the request failed 42 | */ 43 | public String getOf(Action request, Action of) throws Exception { 44 | return this.execute(GET, request, OF, of); 45 | } 46 | 47 | /** 48 | * Request an information from the application without a specific category 49 | * 50 | * @param request The requested type of information 51 | * @return The requested information 52 | * @throws Exception If the request failed 53 | */ 54 | public String get(Action request) throws Exception { 55 | return this.execute(GET, request); 56 | } 57 | 58 | /** 59 | * Execute an AppleScript command. 60 | *

61 | * It basically calls the AppleScript application with the following command:
62 | * osascript -e tell application "{@literal <}application{@literal >}" to {@literal <}action{@literal >} 63 | * 64 | * @param actions The actions to execute 65 | * @return The result of the command 66 | * @throws Exception If the command failed 67 | */ 68 | public String execute(Action... actions) throws Exception { 69 | // Update runtime parameters 70 | String action = Action.toString(actions); 71 | this.runtimeParameters[2] = String.format(GRAMMAR_FORMAT, this.application, action); 72 | 73 | // Execute AppleScript process 74 | Process process = this.runtime.exec(this.runtimeParameters); 75 | int exitCode = process.waitFor(); 76 | if (exitCode == 0) { 77 | // Read response 78 | BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); 79 | StringBuilder builder = new StringBuilder(); 80 | String line; 81 | while ((line = reader.readLine()) != null) { 82 | builder.append(line); 83 | } 84 | return builder.toString(); 85 | } else { 86 | // Handle error message 87 | BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream())); 88 | String line; 89 | StringBuilder builder = new StringBuilder(); 90 | while ((line = reader.readLine()) != null) { 91 | builder.append(line); 92 | } 93 | throw new Exception("AppleScript execution \"" + action + "\" failed with exit code " + exitCode + ": " + builder); 94 | } 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/osx/api/spotify/SpotifyAppleScript.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.osx.api.spotify; 2 | 3 | import de.labystudio.spotifyapi.platform.osx.api.Action; 4 | import de.labystudio.spotifyapi.platform.osx.api.AppleScript; 5 | 6 | /** 7 | * Spotify AppleScript API. 8 | * Wraps all necessary AppleScript commands to interact with the Spotify application. 9 | * 10 | * @author LabyStudio 11 | */ 12 | public class SpotifyAppleScript extends AppleScript { 13 | 14 | public static final Action CURRENT_TRACK = new Action("current", "track"); 15 | 16 | public static final Action ID = new Action("id"); 17 | public static final Action NAME = new Action("name"); 18 | public static final Action ARTIST = new Action("artist"); 19 | public static final Action LENGTH = new Action("duration"); 20 | 21 | public static final Action PLAYER_POSITION = new Action("player", "position"); 22 | public static final Action PLAYER_STATE = new Action("player", "state"); 23 | 24 | public static final Action PLAY_PAUSE = new Action("playpause"); 25 | public static final Action NEXT_TRACK = new Action("next", "track"); 26 | public static final Action PREVIOUS_TRACK = new Action("previous", "track"); 27 | 28 | public SpotifyAppleScript() { 29 | super("Spotify"); 30 | } 31 | 32 | /** 33 | * Get the current track ID without the "spotify:track:" prefix. 34 | * 35 | * @return The current track ID 36 | * @throws Exception If the request failed 37 | */ 38 | public String getTrackId() throws Exception { 39 | return this.getOf(ID, CURRENT_TRACK).substring(14); 40 | } 41 | 42 | /** 43 | * Get the current track name. 44 | * 45 | * @return The current track name 46 | * @throws Exception If the request failed 47 | */ 48 | public String getTrackName() throws Exception { 49 | return this.getOf(NAME, CURRENT_TRACK); 50 | } 51 | 52 | /** 53 | * Get the current track artist. 54 | * 55 | * @return The current track artist 56 | * @throws Exception If the request failed 57 | */ 58 | public String getTrackArtist() throws Exception { 59 | return this.getOf(ARTIST, CURRENT_TRACK); 60 | } 61 | 62 | /** 63 | * Get the current track length in seconds. 64 | * 65 | * @return The current track length in seconds 66 | * @throws Exception If the request failed 67 | */ 68 | public int getTrackLength() throws Exception { 69 | return Integer.parseInt(this.getOf(LENGTH, CURRENT_TRACK)); 70 | } 71 | 72 | /** 73 | * Get the current track position in milliseconds. 74 | * 75 | * @return The current track position in milliseconds 76 | * @throws Exception If the request failed 77 | */ 78 | public int getPlayerPosition() throws Exception { 79 | return (int) (Double.parseDouble(this.get(PLAYER_POSITION)) * 1000); 80 | } 81 | 82 | /** 83 | * Get the current player state. 84 | * It returns true if the current track is playing, false otherwise. 85 | * 86 | * @return The current player state (true if playing, false otherwise) 87 | * @throws Exception If the request failed 88 | */ 89 | public boolean getPlayerState() throws Exception { 90 | return this.get(PLAYER_STATE).equals("playing"); 91 | } 92 | 93 | /** 94 | * Play or pause the current track. 95 | * 96 | * @throws Exception If the request failed 97 | */ 98 | public void playPause() throws Exception { 99 | this.execute(PLAY_PAUSE); 100 | } 101 | 102 | /** 103 | * Skip to the next track. 104 | * 105 | * @throws Exception If the request failed 106 | */ 107 | public void nextTrack() throws Exception { 108 | this.execute(NEXT_TRACK); 109 | } 110 | 111 | /** 112 | * Skip to the previous track. 113 | * 114 | * @throws Exception If the request failed 115 | */ 116 | public void previousTrack() throws Exception { 117 | this.execute(PREVIOUS_TRACK); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Banner](.github/assets/banner.png) 2 | 3 | A Spotify API written in Java to access the current playing song.
4 | There is no need for an access token, any internet connection or a premium account 5 | because the API reads the information directly from the application itself. 6 | 7 | ## Feature Overview 8 | - Track id 9 | - Track title & artist 10 | - Track progress & length 11 | - Playing state (Playing, paused) 12 | - Track cover 13 | - Media keys (Previous song, play/pause & next song) 14 | 15 | #### Supported operating systems: 16 | - Windows 17 | - macOS 18 | - Linux distros that uses systemd 19 | 20 | ## Gradle Setup 21 | ```groovy 22 | repositories { 23 | maven { url 'https://jitpack.io' } 24 | } 25 | 26 | dependencies { 27 | implementation 'com.github.LabyStudio:java-spotify-api:+:all' 28 | } 29 | ``` 30 | 31 | ## Example 32 | Create the API and get the current playing song and position: 33 | ```java 34 | // Create a new SpotifyAPI for your operating system 35 | SpotifyAPI api = SpotifyAPIFactory.createInitialized(); 36 | 37 | // It has no track until the song started playing once 38 | if (api.hasTrack()) { 39 | System.out.println(api.getTrack()); 40 | } 41 | 42 | // It has no position until the song is paused, the position changed or the song changed 43 | if (api.hasPosition()) { 44 | System.out.println(api.getPosition()); 45 | } 46 | ``` 47 | 48 | Register a listener to get notified when the song changes: 49 | ```java 50 | SpotifyAPI localApi = SpotifyAPIFactory.create(); 51 | localApi.registerListener(new SpotifyListener() { 52 | @Override 53 | public void onConnect() { 54 | System.out.println("Connected to Spotify!"); 55 | } 56 | 57 | @Override 58 | public void onTrackChanged(Track track) { 59 | System.out.printf("Track changed: %s (%s)\n", track, formatDuration(track.getLength())); 60 | 61 | if (track.getCoverArt() != null) { 62 | BufferedImage coverArt = track.getCoverArt(); 63 | System.out.println("Track cover: " + coverArt.getWidth() + "x" + coverArt.getHeight()); 64 | } 65 | } 66 | 67 | @Override 68 | public void onPositionChanged(int position) { 69 | if (!localApi.hasTrack()) { 70 | return; 71 | } 72 | 73 | int length = localApi.getTrack().getLength(); 74 | float percentage = 100.0F / length * position; 75 | 76 | System.out.printf( 77 | "Position changed: %s of %s (%d%%)\n", 78 | formatDuration(position), 79 | formatDuration(length), 80 | (int) percentage 81 | ); 82 | } 83 | 84 | @Override 85 | public void onPlayBackChanged(boolean isPlaying) { 86 | System.out.println(isPlaying ? "Song started playing" : "Song stopped playing"); 87 | } 88 | 89 | @Override 90 | public void onSync() { 91 | // System.out.println(formatDuration(api.getPosition())); 92 | } 93 | 94 | @Override 95 | public void onDisconnect(Exception exception) { 96 | System.out.println("Disconnected: " + exception.getMessage()); 97 | 98 | // api.stop(); 99 | } 100 | }); 101 | 102 | // Initialize the API 103 | localApi.initialize(); 104 | ``` 105 | 106 | Request information of any track id using open.spotify.com: 107 | ```java 108 | // Create a secret provider 109 | SecretProvider secretProvider = new DefaultSecretProvider( 110 | // Note: You have to update the secret with the latest TOTP secret from open.spotify.com 111 | // or find a way to retrieve it automatically from their website 112 | Secret.fromString("=n:b#OuEfH\fE])e*K", 10) 113 | ); 114 | 115 | // Create an instance of the Open Spotify API 116 | OpenSpotifyAPI openSpotifyAPI = new OpenSpotifyAPI(secretProvider); 117 | 118 | // Fetch cover art image by track id 119 | BufferedImage coverArt = openSpotifyAPI.requestImage(trackId); 120 | 121 | // Fetch track information by track id 122 | OpenTrack openTrack = openSpotifyAPI.requestOpenTrack(trackId); 123 | ``` 124 | 125 | You can also skip the current song using the Media Key API: 126 | ```java 127 | SpotifyAPI api = SpotifyAPIFactory.createInitialized(); 128 | 129 | // Send media key to the operating system 130 | api.pressMediaKey(MediaKey.NEXT); 131 | ``` 132 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/playback/source/MediaControlPlaybackAccessor.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.playback.source; 2 | 3 | import com.sun.jna.Pointer; 4 | import com.sun.jna.ptr.NativeLongByReference; 5 | import com.sun.jna.ptr.PointerByReference; 6 | import de.labystudio.spotifyapi.platform.windows.api.jna.WindowsMediaControl; 7 | import de.labystudio.spotifyapi.platform.windows.api.playback.PlaybackAccessor; 8 | 9 | /** 10 | * Accessor to read the duration, position and playing state from the Spotify process. 11 | * It uses the Windows Media Control API to retrieve playback information. 12 | * 13 | * @author LabyStudio 14 | */ 15 | public class MediaControlPlaybackAccessor implements PlaybackAccessor { 16 | 17 | private final WindowsMediaControl mediaControl; 18 | 19 | private long playbackPosition; 20 | private long trackDuration; 21 | private boolean isPlaying; 22 | 23 | private String title; 24 | private String artist; 25 | 26 | private byte[] coverArt; 27 | 28 | public MediaControlPlaybackAccessor(WindowsMediaControl mediaControl) { 29 | if (mediaControl == null) { 30 | throw new IllegalArgumentException("MediaControl cannot be null"); 31 | } 32 | this.mediaControl = mediaControl; 33 | } 34 | 35 | @Override 36 | public void updatePlayback() { 37 | this.playbackPosition = this.mediaControl.getPlaybackPosition(); 38 | if (this.playbackPosition == -1) { 39 | throw new IllegalStateException("Playback information unavailable"); 40 | } 41 | 42 | int isPlaying = this.mediaControl.isPlaying(); 43 | if (isPlaying < 0) { 44 | throw new IllegalStateException("Failed to retrieve playback state"); 45 | } 46 | this.isPlaying = isPlaying == 1; // Convert to boolean (1 = playing, 0 = not playing) 47 | } 48 | 49 | @Override 50 | public void updateTrack() { 51 | this.trackDuration = this.mediaControl.getTrackDuration(); 52 | if (this.trackDuration <= 0) { 53 | throw new IllegalStateException("Track duration is invalid or unavailable"); 54 | } 55 | 56 | // Get the track title 57 | Pointer titlePtr = this.mediaControl.getTrackTitle(); 58 | if (titlePtr == null) { 59 | throw new IllegalStateException("Track title pointer is null"); 60 | } 61 | this.title = titlePtr.getString(0, "UTF-8"); 62 | this.mediaControl.freeString(titlePtr); 63 | 64 | // Get the artist name 65 | Pointer artistPtr = this.mediaControl.getArtistName(); 66 | if (artistPtr == null) { 67 | throw new IllegalStateException("Artist name pointer is null"); 68 | } 69 | this.artist = artistPtr.getString(0, "UTF-8"); 70 | this.mediaControl.freeString(artistPtr); 71 | 72 | // Get the cover art 73 | PointerByReference bufferRef = new PointerByReference(); 74 | NativeLongByReference lengthRef = new NativeLongByReference(); 75 | if (this.mediaControl.getCoverArt(bufferRef, lengthRef)) { 76 | Pointer buffer = bufferRef.getValue(); 77 | 78 | if (buffer == null) { 79 | this.coverArt = null; // No cover art available 80 | } else { 81 | int length = lengthRef.getValue().intValue(); 82 | this.coverArt = buffer.getByteArray(0, length); 83 | this.mediaControl.freeCoverArt(buffer); 84 | } 85 | } 86 | } 87 | 88 | @Override 89 | public boolean isValid() { 90 | return this.playbackPosition >= 0 && this.trackDuration > 0 && this.playbackPosition <= this.trackDuration; 91 | } 92 | 93 | @Override 94 | public int getLength() { 95 | return (int) this.trackDuration; 96 | } 97 | 98 | @Override 99 | public int getPosition() { 100 | return (int) this.playbackPosition; 101 | } 102 | 103 | @Override 104 | public boolean isPlaying() { 105 | return this.isPlaying; 106 | } 107 | 108 | @Override 109 | public String getTitle() { 110 | return this.title; 111 | } 112 | 113 | @Override 114 | public String getArtist() { 115 | return this.artist; 116 | } 117 | 118 | @Override 119 | public byte[] getCoverArt() { 120 | return this.coverArt != null ? this.coverArt : null; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/AbstractTickSpotifyAPI.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform; 2 | 3 | import de.labystudio.spotifyapi.SpotifyAPI; 4 | import de.labystudio.spotifyapi.SpotifyListener; 5 | import de.labystudio.spotifyapi.config.SpotifyConfiguration; 6 | import de.labystudio.spotifyapi.open.OpenSpotifyAPI; 7 | 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | import java.util.concurrent.Executors; 11 | import java.util.concurrent.ScheduledExecutorService; 12 | import java.util.concurrent.ScheduledFuture; 13 | import java.util.concurrent.TimeUnit; 14 | 15 | /** 16 | * Abstract tick class for SpotifyAPI implementations. 17 | * 18 | * @author LabyStudio 19 | */ 20 | public abstract class AbstractTickSpotifyAPI implements SpotifyAPI { 21 | 22 | protected static final long TICK_INTERVAL = 1000L; // 1 second 23 | 24 | /** 25 | * The list of all Spotify listeners. 26 | */ 27 | protected final List listeners = new ArrayList<>(); 28 | 29 | private OpenSpotifyAPI openAPI; 30 | 31 | protected SpotifyConfiguration configuration; 32 | 33 | private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); 34 | 35 | private ScheduledFuture task; 36 | 37 | private long timeLastException = -1; 38 | 39 | /** 40 | * Initialize the SpotifyAPI abstract tick implementation. 41 | * It will create a task that will update the current track and position every second. 42 | * 43 | * @return the initialized SpotifyAPI 44 | * @throws IllegalStateException if the API is already initialized or has been shutdown 45 | */ 46 | @Override 47 | public SpotifyAPI initialize(SpotifyConfiguration configuration) { 48 | synchronized (this) { 49 | this.configuration = configuration; 50 | 51 | if (this.executor.isShutdown()) { 52 | throw new IllegalStateException("This SpotifyAPI has been shutdown and cannot be reused"); 53 | } 54 | 55 | if (this.isInitialized()) { 56 | throw new IllegalStateException("This SpotifyAPI is already initialized"); 57 | } 58 | 59 | this.onInitialized(); 60 | 61 | // Start task to update every second 62 | this.task = this.executor.scheduleWithFixedDelay( 63 | this::onInternalTick, 64 | 0L, 65 | TICK_INTERVAL, 66 | TimeUnit.MILLISECONDS 67 | ); 68 | 69 | // Initial tick to provide sync data (see SpotifyDirectTest) 70 | this.onInternalTick(); 71 | } 72 | return this; 73 | } 74 | 75 | protected void onInitialized() { 76 | // No default implementation 77 | } 78 | 79 | protected synchronized void onInternalTick() { 80 | try { 81 | // Check if we passed the exception timeout 82 | long timeSinceLastException = System.currentTimeMillis() - this.timeLastException; 83 | if (timeSinceLastException < this.configuration.getExceptionReconnectDelay()) { 84 | return; 85 | } 86 | 87 | this.onTick(); 88 | } catch (Exception e) { 89 | this.timeLastException = System.currentTimeMillis(); 90 | this.stop(); 91 | 92 | // Fire on disconnect 93 | this.listeners.forEach(listener -> listener.onDisconnect(e)); 94 | 95 | // Restart the process 96 | if (this.configuration.isAutoReconnect()) { 97 | this.initialize(this.configuration); 98 | } 99 | } 100 | } 101 | 102 | protected abstract void onTick() throws Exception; 103 | 104 | @Override 105 | public void registerListener(SpotifyListener listener) { 106 | this.listeners.add(listener); 107 | } 108 | 109 | @Override 110 | public void unregisterListener(SpotifyListener listener) { 111 | this.listeners.remove(listener); 112 | } 113 | 114 | @Override 115 | public boolean isInitialized() { 116 | return this.task != null; 117 | } 118 | 119 | @Override 120 | public SpotifyConfiguration getConfiguration() { 121 | return this.configuration; 122 | } 123 | 124 | @Override 125 | public void stop() { 126 | synchronized (this) { 127 | if (this.task != null) { 128 | this.task.cancel(true); 129 | this.task = null; 130 | } 131 | } 132 | } 133 | 134 | @Override 135 | public void shutdown() { 136 | this.stop(); 137 | this.executor.shutdownNow(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/linux/api/DBusSend.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.linux.api; 2 | 3 | import de.labystudio.spotifyapi.platform.linux.api.model.InterfaceMember; 4 | import de.labystudio.spotifyapi.platform.linux.api.model.Parameter; 5 | import de.labystudio.spotifyapi.platform.linux.api.model.Variant; 6 | 7 | import java.io.BufferedReader; 8 | import java.io.InputStreamReader; 9 | 10 | /** 11 | * Java wrapper for the dbus-send application 12 | *

13 | * The dbus-send command is used to send a message to a D-Bus message bus. 14 | * There are two well-known message buses: 15 | * - the systemwide message bus (installed on many systems as the "messagebus" service) 16 | * - the per-user-login-session message bus (started each time a user logs in). 17 | *

18 | * The "system" parameter and "session" parameter options direct dbus-send to send messages to the system or session buses respectively. 19 | * If neither is specified, dbus-send sends to the session bus. 20 | *

21 | * Nearly all uses of dbus-send must provide the "dest" parameter which is the name of 22 | * a connection on the bus to send the message to. If the "dest" parameter is omitted, no destination is set. 23 | *

24 | * The object path and the name of the message to send must always be specified. 25 | * Following arguments, if any, are the message contents (message arguments). 26 | * These are given as type-specified values and may include containers (arrays, dicts, and variants). 27 | * 28 | * @author LabyStudio 29 | */ 30 | public class DBusSend { 31 | 32 | private static final Parameter PARAM_PRINT_REPLY = new Parameter("print-reply"); 33 | private static final InterfaceMember INTERFACE_GET = new InterfaceMember("org.freedesktop.DBus.Properties.Get"); 34 | 35 | private final Parameter[] parameters; 36 | private final String objectPath; 37 | private final Runtime runtime; 38 | 39 | /** 40 | * Creates a new DBusSend API for a specific application 41 | * 42 | * @param parameters The parameters to use 43 | * @param objectPath The object path to use 44 | */ 45 | public DBusSend(Parameter[] parameters, String objectPath) { 46 | this.parameters = parameters; 47 | this.objectPath = objectPath; 48 | this.runtime = Runtime.getRuntime(); 49 | } 50 | 51 | /** 52 | * Request an information from the application 53 | * 54 | * @param keys The requested type of information 55 | * @return The requested information 56 | * @throws Exception If the request failed 57 | */ 58 | public Variant get(String... keys) throws Exception { 59 | String[] contents = new String[keys.length]; 60 | for (int i = 0; i < keys.length; i++) { 61 | contents[i] = String.format("string:%s", keys[i]); 62 | } 63 | return this.send(INTERFACE_GET, contents); 64 | } 65 | 66 | /** 67 | * Execute an DBusSend command. 68 | * 69 | * @param interfaceMember The interface member to execute 70 | * @param contents The contents to send 71 | * @return The result of the command 72 | * @throws Exception If the command failed 73 | */ 74 | public Variant send(InterfaceMember interfaceMember, String... contents) throws Exception { 75 | // Build arguments 76 | String[] arguments = new String[2 + this.parameters.length + 2 + contents.length]; 77 | arguments[0] = "dbus-send"; 78 | arguments[1] = PARAM_PRINT_REPLY.toString(); 79 | for (int i = 0; i < this.parameters.length; i++) { 80 | arguments[2 + i] = this.parameters[i].toString(); 81 | } 82 | arguments[2 + this.parameters.length] = this.objectPath; 83 | arguments[2 + this.parameters.length + 1] = interfaceMember.toString(); 84 | for (int i = 0; i < contents.length; i++) { 85 | arguments[2 + this.parameters.length + 2 + i] = contents[i]; 86 | } 87 | 88 | // Execute dbus-send process 89 | Process process = this.runtime.exec(arguments); 90 | int exitCode = process.waitFor(); 91 | if (exitCode == 0) { 92 | // Read response 93 | BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); 94 | StringBuilder builder = new StringBuilder(); 95 | String response; 96 | while ((response = reader.readLine()) != null) { 97 | if (response.startsWith("method ")) { 98 | continue; 99 | } 100 | builder.append(response).append("\n"); 101 | } 102 | if (builder.toString().isEmpty()) { 103 | return new Variant("success", true); 104 | } 105 | return Variant.parse(builder.toString()); 106 | } else { 107 | // Handle error message 108 | BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream())); 109 | String line; 110 | StringBuilder builder = new StringBuilder(); 111 | while ((line = reader.readLine()) != null) { 112 | builder.append(line); 113 | } 114 | throw new Exception("dbus-send execution \"" + String.join(" ", arguments) + "\" failed with exit code " + exitCode + ": " + builder); 115 | } 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/osx/OSXSpotifyApi.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.osx; 2 | 3 | import de.labystudio.spotifyapi.SpotifyListener; 4 | import de.labystudio.spotifyapi.model.MediaKey; 5 | import de.labystudio.spotifyapi.model.Track; 6 | import de.labystudio.spotifyapi.platform.AbstractTickSpotifyAPI; 7 | import de.labystudio.spotifyapi.platform.osx.api.spotify.SpotifyAppleScript; 8 | 9 | import java.util.Objects; 10 | 11 | /** 12 | * OSX implementation of the SpotifyAPI. 13 | * It uses the AppleScript API to access the Spotify application. 14 | * 15 | * @author LabyStudio 16 | */ 17 | public class OSXSpotifyApi extends AbstractTickSpotifyAPI { 18 | 19 | private final SpotifyAppleScript appleScript = new SpotifyAppleScript(); 20 | 21 | private boolean connected = false; 22 | 23 | private Track currentTrack; 24 | private int currentPosition = -1; 25 | private boolean isPlaying; 26 | 27 | private long lastTimePositionUpdated; 28 | 29 | @Override 30 | protected void onTick() throws Exception { 31 | String trackId = this.appleScript.getTrackId(); 32 | 33 | // Handle on connect 34 | if (!this.connected && !trackId.isEmpty()) { 35 | this.connected = true; 36 | this.listeners.forEach(SpotifyListener::onConnect); 37 | } 38 | 39 | // Handle track changes 40 | if (!Objects.equals(trackId, this.currentTrack == null ? null : this.currentTrack.getId())) { 41 | String trackName = this.appleScript.getTrackName(); 42 | String trackArtist = this.appleScript.getTrackArtist(); 43 | int trackLength = this.appleScript.getTrackLength(); 44 | 45 | boolean isFirstTrack = !this.hasTrack(); 46 | 47 | Track track = new Track( 48 | trackId, 49 | trackName, 50 | trackArtist, 51 | trackLength, 52 | null // TODO: Add cover art support if possible 53 | ); 54 | this.currentTrack = track; 55 | 56 | // Fire on track changed 57 | this.listeners.forEach(listener -> listener.onTrackChanged(track)); 58 | 59 | // Reset position on song change 60 | if (!isFirstTrack) { 61 | this.updatePosition(0); 62 | } 63 | } 64 | 65 | // Handle is playing changes 66 | boolean isPlaying = this.appleScript.getPlayerState(); 67 | if (isPlaying != this.isPlaying) { 68 | this.isPlaying = isPlaying; 69 | 70 | // Fire on play back changed 71 | this.listeners.forEach(listener -> listener.onPlayBackChanged(isPlaying)); 72 | } 73 | 74 | // Handle position changes 75 | int position = this.appleScript.getPlayerPosition(); 76 | if (!this.hasPosition() || Math.abs(position - this.getPosition()) > 1000) { 77 | this.updatePosition(position); 78 | } 79 | 80 | // Fire keep alive 81 | this.listeners.forEach(SpotifyListener::onSync); 82 | } 83 | 84 | @Override 85 | public void stop() { 86 | super.stop(); 87 | 88 | this.connected = false; 89 | this.currentTrack = null; 90 | this.currentPosition = -1; 91 | this.isPlaying = false; 92 | this.lastTimePositionUpdated = 0; 93 | } 94 | 95 | private void updatePosition(int position) { 96 | if (position == this.currentPosition) { 97 | return; 98 | } 99 | 100 | // Update position known state 101 | this.currentPosition = position; 102 | this.lastTimePositionUpdated = System.currentTimeMillis(); 103 | 104 | // Fire on position changed 105 | this.listeners.forEach(listener -> listener.onPositionChanged(position)); 106 | } 107 | 108 | @Override 109 | public void pressMediaKey(MediaKey mediaKey) { 110 | try { 111 | switch (mediaKey) { 112 | case PLAY_PAUSE: 113 | this.appleScript.playPause(); 114 | break; 115 | case NEXT: 116 | this.appleScript.nextTrack(); 117 | break; 118 | case PREV: 119 | this.appleScript.previousTrack(); 120 | break; 121 | } 122 | } catch (Exception e) { 123 | this.listeners.forEach(listener -> listener.onDisconnect(e)); 124 | this.connected = false; 125 | } 126 | } 127 | 128 | @Override 129 | public int getPosition() { 130 | if (!this.hasPosition()) { 131 | throw new IllegalStateException("Position is not known yet"); 132 | } 133 | 134 | if (this.isPlaying) { 135 | // Interpolate position 136 | long timePassed = System.currentTimeMillis() - this.lastTimePositionUpdated; 137 | return this.currentPosition + (int) timePassed; 138 | } else { 139 | return this.currentPosition; 140 | } 141 | } 142 | 143 | @Override 144 | public Track getTrack() { 145 | return this.currentTrack; 146 | } 147 | 148 | @Override 149 | public boolean isPlaying() { 150 | return this.isPlaying; 151 | } 152 | 153 | @Override 154 | public boolean isConnected() { 155 | return this.connected; 156 | } 157 | 158 | @Override 159 | public boolean hasPosition() { 160 | return this.currentPosition != -1; 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/SpotifyAPI.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi; 2 | 3 | import de.labystudio.spotifyapi.config.SpotifyConfiguration; 4 | import de.labystudio.spotifyapi.model.MediaKey; 5 | import de.labystudio.spotifyapi.model.Track; 6 | 7 | import java.util.concurrent.CompletableFuture; 8 | 9 | /** 10 | * This is the main interface for the SpotifyAPI. 11 | * It is used to get the current track and the current position of a song. 12 | * There is a method called {@link #registerListener(SpotifyListener)} to register a listener to be notified about changes. 13 | * 14 | * @author LabyStudio 15 | */ 16 | public interface SpotifyAPI { 17 | 18 | /** 19 | * Initialize the SpotifyAPI and connect to the Spotify process. 20 | * Initializing the api will block the current thread until the connection is established. 21 | * It will use a default configuration. 22 | * 23 | * @return the initialized SpotifyAPI 24 | */ 25 | default SpotifyAPI initialize() { 26 | return this.initialize(new SpotifyConfiguration.Builder().build()); 27 | } 28 | 29 | /** 30 | * Initialize the SpotifyAPI and connect to the Spotify process. 31 | * Initializing the api will block the current thread until the connection is established. 32 | * 33 | * @param configuration the configuration for the api 34 | * @return the initialized SpotifyAPI 35 | */ 36 | SpotifyAPI initialize(SpotifyConfiguration configuration); 37 | 38 | /** 39 | * Initialize the SpotifyAPI and connect to the Spotify process asynchronously. 40 | * 41 | * @param configuration the configuration for the api 42 | * @return a future that will contain the initialized SpotifyAPI 43 | */ 44 | default CompletableFuture initializeAsync(SpotifyConfiguration configuration) { 45 | return CompletableFuture.supplyAsync(() -> this.initialize(configuration)); 46 | } 47 | 48 | /** 49 | * Initialize the SpotifyAPI and connect to the Spotify process asynchronously. 50 | * It will use a default configuration. 51 | * 52 | * @return a future that will contain the initialized SpotifyAPI 53 | */ 54 | default CompletableFuture initializeAsync() { 55 | return CompletableFuture.supplyAsync(this::initialize); 56 | } 57 | 58 | /** 59 | * Returns the current track that is playing right now 60 | * It can be null if the api haven't received any playback changes yet. 61 | * 62 | * @return the current track 63 | */ 64 | Track getTrack(); 65 | 66 | /** 67 | * Returns true if the current track is playing or cached 68 | * 69 | * @return true if the current track is playing or cached 70 | */ 71 | default boolean hasTrack() { 72 | return this.getTrack() != null; 73 | } 74 | 75 | /** 76 | * Returns the current interpolated position of the song in milliseconds. 77 | * To check if the position is known, use {@link #hasPosition()} 78 | * 79 | * @return the current position of the song in milliseconds 80 | * @throws IllegalStateException if the position isn't known and the api haven't received any playback changes yet 81 | */ 82 | int getPosition(); 83 | 84 | /** 85 | * Returns true if the position of the current track is known. 86 | * The position becomes known after the track has changed or after the song has been paused. 87 | * 88 | * @return true if the position of the current track is known 89 | */ 90 | boolean hasPosition(); 91 | 92 | /** 93 | * Returns true if the current track is playing. 94 | * 95 | * @return true if the current track is playing 96 | */ 97 | boolean isPlaying(); 98 | 99 | /** 100 | * Send a key pres of a media key to the system.
101 | * The key can be one of the following: 102 | *

    103 | *
  • {@link MediaKey#PLAY_PAUSE}
  • 104 | *
  • {@link MediaKey#NEXT}
  • 105 | *
  • {@link MediaKey#PREV}
  • 106 | *
107 | * 108 | * @param mediaKey the key to send 109 | */ 110 | void pressMediaKey(MediaKey mediaKey); 111 | 112 | /** 113 | * Returns true if the api is connected to the Spotify application. 114 | * 115 | * @return true if the api is connected to the Spotify application 116 | */ 117 | boolean isConnected(); 118 | 119 | /** 120 | * Returns true if the background process is running. 121 | * 122 | * @return true if the background process is running 123 | */ 124 | boolean isInitialized(); 125 | 126 | /** 127 | * Registers a listener to be notified about changes. 128 | * 129 | * @param listener the listener to register 130 | */ 131 | void registerListener(SpotifyListener listener); 132 | 133 | /** 134 | * Unregisters a listener. 135 | * 136 | * @param listener the listener to unregister 137 | */ 138 | void unregisterListener(SpotifyListener listener); 139 | 140 | /** 141 | * Returns the current set configuration of the api. 142 | * 143 | * @return the current set configuration of the api 144 | */ 145 | SpotifyConfiguration getConfiguration(); 146 | 147 | /** 148 | * Disconnect from the Spotify application and stop all background tasks. 149 | */ 150 | void stop(); 151 | 152 | /** 153 | * Similar to {@link SpotifyAPI#stop()} but releases any held resources. 154 | *

155 | * Should only be called if there is no intent to reuse this instance. 156 | */ 157 | void shutdown(); 158 | } 159 | -------------------------------------------------------------------------------- /src/test/java/SpotifyEnableDevMode.java: -------------------------------------------------------------------------------- 1 | import java.io.ByteArrayOutputStream; 2 | import java.io.IOException; 3 | import java.io.InputStream; 4 | import java.nio.charset.StandardCharsets; 5 | import java.nio.file.Files; 6 | import java.nio.file.Path; 7 | import java.nio.file.Paths; 8 | import java.util.Enumeration; 9 | import java.util.zip.ZipEntry; 10 | import java.util.zip.ZipFile; 11 | import java.util.zip.ZipOutputStream; 12 | 13 | public class SpotifyEnableDevMode { 14 | 15 | public static void main(String[] args) throws IOException { 16 | boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); 17 | 18 | Path spotifyApp = isWindows 19 | ? Paths.get(System.getenv("APPDATA"), "Spotify") 20 | : Paths.get("/opt/spotify"); 21 | if (!Files.exists(spotifyApp)) { 22 | System.err.println("Spotify application directory not found: " + spotifyApp); 23 | return; 24 | } 25 | enableEmployeeMode(spotifyApp); 26 | 27 | Path spotifyCache = isWindows 28 | ? Paths.get(System.getenv("LOCALAPPDATA"), "Spotify") 29 | : Paths.get("/home", System.getProperty("user.name"), ".cache", "spotify"); 30 | if (!Files.exists(spotifyCache)) { 31 | System.err.println("Spotify cache directory not found: " + spotifyCache); 32 | return; 33 | } 34 | 35 | enableDevTools(spotifyCache); 36 | } 37 | 38 | private static void enableDevTools(Path spotifyLocal) throws IOException { 39 | Path file = spotifyLocal.resolve("offline.bnk"); 40 | TextPatcher patcher = (fileName, content) -> { 41 | content = content.replaceAll("(?<=app-developer..|app-developer>)0", "2"); 42 | System.out.println("Patched " + fileName + " to enable developer tools."); 43 | return content; 44 | }; 45 | patchFile(file, patcher); 46 | } 47 | 48 | private static void enableEmployeeMode(Path spotifyAppData) throws IOException { 49 | Path appsDir = spotifyAppData.resolve("Apps"); 50 | Path xpuiSpa = appsDir.resolve("xpui.spa"); 51 | patchArchive(xpuiSpa, (TextPatcher) (fileName, content) -> { 52 | if (!fileName.endsWith(".js")) { 53 | return content; // Only patch JavaScript files 54 | } 55 | if (content.contains(".employee.isEmployee")) { 56 | content = content.replace( 57 | ".employee.isEmployee", 58 | ".autoPlay" 59 | ); 60 | System.out.println("Patched " + fileName + " to enable employee mode."); 61 | } 62 | return content; 63 | }); 64 | } 65 | 66 | private static void patchFile(Path archive, Patcher patcher) throws IOException { 67 | Files.write(archive, patcher.patch(archive.getFileName().toString(), Files.readAllBytes(archive))); 68 | } 69 | 70 | private static void patchArchive(Path archive, Patcher patcher) throws IOException { 71 | if (!Files.exists(archive)) { 72 | System.err.println("Archive not found: " + archive); 73 | return; 74 | } 75 | 76 | if (!Files.isWritable(archive)) { 77 | System.err.println("No write permission for archive: " + archive); 78 | return; 79 | } 80 | 81 | // Move archive to temp location 82 | Path tempArchive = Paths.get(System.getProperty("java.io.tmpdir"), "spotify-temp-" + archive.getFileName()); 83 | Files.copy(archive, tempArchive); 84 | 85 | // Write patched archive to original location 86 | try ( 87 | ZipFile zipFile = new ZipFile(tempArchive.toFile()); 88 | ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(archive)) 89 | ) { 90 | for (Enumeration entries = zipFile.entries(); entries.hasMoreElements(); ) { 91 | ZipEntry entryIn = entries.nextElement(); 92 | 93 | // Read bytes of temp archive entry 94 | try (InputStream is = zipFile.getInputStream(entryIn)) { 95 | ByteArrayOutputStream inputBytes = new ByteArrayOutputStream(); 96 | byte[] buf = new byte[1024]; 97 | int len; 98 | while ((len = is.read(buf)) > 0) { 99 | inputBytes.write(buf, 0, len); 100 | } 101 | 102 | // Patch 103 | byte[] outputBytes = patcher.patch(entryIn.getName(), inputBytes.toByteArray()); 104 | 105 | // Write patched bytes to new archive 106 | zos.putNextEntry(entryIn); 107 | zos.write(outputBytes); 108 | } 109 | } 110 | } finally { 111 | // Delete temp archive 112 | Files.deleteIfExists(tempArchive); 113 | } 114 | } 115 | 116 | private interface TextPatcher extends Patcher { 117 | 118 | @Override 119 | default byte[] patch(String fileName, byte[] payload) throws IOException { 120 | String content = new String(payload, StandardCharsets.ISO_8859_1); 121 | String patched = this.patch(fileName, content); 122 | return patched.getBytes(StandardCharsets.ISO_8859_1); 123 | } 124 | 125 | String patch(String fileName, String content) throws IOException; 126 | 127 | } 128 | 129 | private interface Patcher { 130 | byte[] patch(String fileName, byte[] payload) throws IOException; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/linux/LinuxSpotifyApi.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.linux; 2 | 3 | import de.labystudio.spotifyapi.SpotifyListener; 4 | import de.labystudio.spotifyapi.model.MediaKey; 5 | import de.labystudio.spotifyapi.model.Track; 6 | import de.labystudio.spotifyapi.platform.AbstractTickSpotifyAPI; 7 | import de.labystudio.spotifyapi.platform.linux.api.MPRISCommunicator; 8 | import de.labystudio.spotifyapi.platform.linux.api.model.Metadata; 9 | 10 | import javax.imageio.ImageIO; 11 | import java.awt.image.BufferedImage; 12 | import java.net.URL; 13 | import java.util.Objects; 14 | 15 | /** 16 | * Linux implementation of the SpotifyAPI. 17 | * It uses the MPRIS to access the Spotify's media control and metadata. 18 | * 19 | * @author holybaechu, LabyStudio 20 | * Thanks for LabyStudio for many code snippets. 21 | */ 22 | public class LinuxSpotifyApi extends AbstractTickSpotifyAPI { 23 | 24 | private boolean connected = false; 25 | 26 | private Track currentTrack; 27 | private int currentPosition = -1; 28 | private boolean isPlaying; 29 | 30 | private long lastTimePositionUpdated; 31 | 32 | private final MPRISCommunicator mediaPlayer = new MPRISCommunicator(); 33 | 34 | @Override 35 | protected void onTick() throws Exception { 36 | Metadata metadata = this.mediaPlayer.readMetadata(); 37 | String trackId = metadata.getTrackId(); 38 | 39 | // Handle on connect 40 | if (!this.connected) { 41 | this.connected = true; 42 | this.listeners.forEach(SpotifyListener::onConnect); 43 | } 44 | 45 | // Handle track changes 46 | String currentTrackId = this.currentTrack == null ? null : this.currentTrack.getId(); 47 | if (!Objects.equals(trackId, currentTrackId)) { 48 | String trackName = metadata.getTrackName(); 49 | String trackArtist = metadata.getArtistsJoined(); 50 | int trackLength = metadata.getTrackLength(); 51 | BufferedImage coverArt = this.toBufferedImage(metadata.getArtUrl()); 52 | 53 | boolean isFirstTrack = !this.hasTrack(); 54 | 55 | Track track = new Track(trackId, trackName, trackArtist, trackLength, coverArt); 56 | this.currentTrack = track; 57 | 58 | // Fire on track changed 59 | this.listeners.forEach(listener -> listener.onTrackChanged(track)); 60 | 61 | // Reset position on song change 62 | if (!isFirstTrack) { 63 | this.updatePosition(0); 64 | } 65 | } 66 | 67 | // Handle is playing changes 68 | boolean isPlaying = this.mediaPlayer.readIsPlaying(); 69 | if (isPlaying != this.isPlaying) { 70 | this.isPlaying = isPlaying; 71 | 72 | // Fire on play back changed 73 | this.listeners.forEach(listener -> listener.onPlayBackChanged(isPlaying)); 74 | } 75 | 76 | // Handle position changes 77 | int position = this.mediaPlayer.readPosition(); 78 | if (!this.hasPosition() || Math.abs(position - this.getPosition()) >= 1000) { 79 | this.updatePosition(position); 80 | } 81 | 82 | // Fire keep alive 83 | this.listeners.forEach(SpotifyListener::onSync); 84 | } 85 | 86 | @Override 87 | public void stop() { 88 | super.stop(); 89 | 90 | this.connected = false; 91 | this.currentTrack = null; 92 | this.currentPosition = -1; 93 | this.isPlaying = false; 94 | this.lastTimePositionUpdated = 0; 95 | } 96 | 97 | private void updatePosition(int position) { 98 | if (position == this.currentPosition) { 99 | return; 100 | } 101 | 102 | // Update position known state 103 | this.currentPosition = position; 104 | this.lastTimePositionUpdated = System.currentTimeMillis(); 105 | 106 | // Fire on position changed 107 | this.listeners.forEach(listener -> listener.onPositionChanged(position)); 108 | } 109 | 110 | @Override 111 | public void pressMediaKey(MediaKey mediaKey) { 112 | try { 113 | switch (mediaKey) { 114 | case PLAY_PAUSE: 115 | this.mediaPlayer.playPause(); 116 | break; 117 | case NEXT: 118 | this.mediaPlayer.next(); 119 | break; 120 | case PREV: 121 | this.mediaPlayer.previous(); 122 | break; 123 | } 124 | } catch (Exception e) { 125 | this.listeners.forEach(listener -> listener.onDisconnect(e)); 126 | this.connected = false; 127 | } 128 | } 129 | 130 | @Override 131 | public int getPosition() { 132 | if (!this.hasPosition()) { 133 | throw new IllegalStateException("Position is not known yet"); 134 | } 135 | 136 | if (this.isPlaying) { 137 | // Interpolate position 138 | long timePassed = System.currentTimeMillis() - this.lastTimePositionUpdated; 139 | return this.currentPosition + (int) timePassed; 140 | } else { 141 | return this.currentPosition; 142 | } 143 | } 144 | 145 | @Override 146 | public Track getTrack() { 147 | return this.currentTrack; 148 | } 149 | 150 | @Override 151 | public boolean isPlaying() { 152 | return this.isPlaying; 153 | } 154 | 155 | @Override 156 | public boolean isConnected() { 157 | return this.connected; 158 | } 159 | 160 | @Override 161 | public boolean hasPosition() { 162 | return this.currentPosition != -1; 163 | } 164 | 165 | private BufferedImage toBufferedImage(String artUrl) { 166 | if (artUrl == null || artUrl.isEmpty()) { 167 | return null; // No cover art available 168 | } 169 | try { 170 | return ImageIO.read(new URL(artUrl)); 171 | } catch (Throwable e) { 172 | e.printStackTrace(); 173 | return null; // Failed to load cover art 174 | } 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/spotify/SpotifyProcess.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api.spotify; 2 | 3 | import de.labystudio.spotifyapi.model.Track; 4 | import de.labystudio.spotifyapi.platform.windows.api.WinProcess; 5 | import de.labystudio.spotifyapi.platform.windows.api.jna.Psapi; 6 | import de.labystudio.spotifyapi.platform.windows.api.jna.WindowsMediaControl; 7 | import de.labystudio.spotifyapi.platform.windows.api.playback.PlaybackAccessor; 8 | import de.labystudio.spotifyapi.platform.windows.api.playback.source.LegacyPlaybackAccessor; 9 | import de.labystudio.spotifyapi.platform.windows.api.playback.source.MediaControlPlaybackAccessor; 10 | 11 | /** 12 | * This class represents the Spotify Windows application. 13 | * 14 | * @author LabyStudio 15 | */ 16 | public class SpotifyProcess extends WinProcess { 17 | 18 | private static final boolean DEBUG = System.getProperty("SPOTIFY_API_DEBUG") != null; 19 | 20 | // Spotify track id 21 | private static final String PREFIX_SPOTIFY_TRACK = "spotify:track:"; 22 | private static final long[] OFFSETS_TRACK_ID = { 23 | 0x18bc90, // 64-Bit (1.2.66.447.g4e37e896) 24 | 0x154A60, // 64-Bit (1.2.26.1187.g36b715a1) 25 | 0x14FA30, // 64-Bit (1.2.21.1104.g42cf0a50) 26 | 0x106198, // 32-Bit (1.2.21.1104.g42cf0a50) 27 | 0x14C9F0, // 64-Bit (Old) 28 | 0x102178, // 32-Bit (Old) 29 | 0x1499F0, // 64-Bit (Old) 30 | 0xFEFE8 // 32-Bit (Old) 31 | }; 32 | 33 | private final long addressTrackId; 34 | private final PlaybackAccessor playbackAccessor; 35 | 36 | /** 37 | * Creates a new instance of the {@link SpotifyProcess} class. 38 | * It will immediately try to connect to the Spotify application. 39 | * 40 | * @throws IllegalStateException if the Spotify process could not be found. 41 | */ 42 | public SpotifyProcess(WindowsMediaControl mediaControl) { 43 | super("Spotify.exe"); 44 | 45 | if (DEBUG) { 46 | System.out.println("Spotify process loaded! Searching for addresses..."); 47 | } 48 | 49 | long timeScanStart = System.currentTimeMillis(); 50 | 51 | // Find the track id address in the memory 52 | this.addressTrackId = this.findTrackIdAddress(); 53 | 54 | if (DEBUG) { 55 | System.out.println("Scanning took " + (System.currentTimeMillis() - timeScanStart) + "ms"); 56 | } 57 | 58 | PlaybackAccessor accessor; 59 | try { 60 | if (mediaControl == null) { 61 | throw new IllegalArgumentException("MediaControl not available"); 62 | } 63 | 64 | // Create accessor for playback control 65 | accessor = new MediaControlPlaybackAccessor(mediaControl); 66 | } catch (Throwable e) { 67 | e.printStackTrace(); 68 | 69 | // We can continue without Media Control access but some features may not work 70 | accessor = new LegacyPlaybackAccessor(this); 71 | } 72 | this.playbackAccessor = accessor; 73 | } 74 | 75 | private long findTrackIdAddress() { 76 | Psapi.ModuleInfo chromeElfModule = this.getModuleInfo("chrome_elf.dll"); 77 | if (chromeElfModule == null) { 78 | throw new IllegalStateException("Could not find chrome_elf.dll module"); 79 | } 80 | 81 | // Find address of track id (Located in the chrome_elf.dll module) 82 | long chromeElfAddress = chromeElfModule.getBaseOfDll(); 83 | 84 | // Check all offsets for valid track id 85 | long addressTrackId = -1; 86 | long minTrackIdOffset = Long.MAX_VALUE; 87 | long maxTrackIdOffset = Long.MIN_VALUE; 88 | for (long trackIdOffset : OFFSETS_TRACK_ID) { 89 | // Get min and max of hardcoded offset 90 | minTrackIdOffset = Math.min(minTrackIdOffset, trackIdOffset); 91 | maxTrackIdOffset = Math.max(maxTrackIdOffset, trackIdOffset); 92 | 93 | // Check if the hardcoded offset is valid 94 | long targetAddressTrackId = chromeElfAddress + trackIdOffset; 95 | if (Track.isTrackIdValid(this.readTrackId(targetAddressTrackId))) { 96 | // If the offset works, exit the loop 97 | addressTrackId = targetAddressTrackId; 98 | break; 99 | } 100 | } 101 | 102 | // If the hardcoded offsets are not valid, try to find it dynamically 103 | if (addressTrackId == -1) { 104 | if (DEBUG) { 105 | System.out.println("Could not find track id with hardcoded offsets. Trying to find it dynamically..."); 106 | } 107 | 108 | long threshold = (maxTrackIdOffset - minTrackIdOffset) * 3; 109 | long scanAddressFrom = chromeElfAddress + minTrackIdOffset - threshold; 110 | long scanAddressTo = chromeElfAddress + maxTrackIdOffset + threshold; 111 | addressTrackId = this.findAddressOfText(scanAddressFrom, scanAddressTo, PREFIX_SPOTIFY_TRACK, (address, index) -> { 112 | return Track.isTrackIdValid(this.readTrackId(address)); 113 | }); 114 | } 115 | 116 | if (addressTrackId == -1) { 117 | throw new IllegalStateException("Could not find track id in memory"); 118 | } 119 | 120 | if (DEBUG) { 121 | System.out.printf( 122 | "Found track id address: %s (+%s) [%s%s]%n", 123 | Long.toHexString(addressTrackId), 124 | Long.toHexString(addressTrackId - chromeElfAddress), 125 | PREFIX_SPOTIFY_TRACK, 126 | this.readTrackId(addressTrackId) 127 | ); 128 | } 129 | return addressTrackId; 130 | } 131 | 132 | /** 133 | * Read the track id from the memory. 134 | * 135 | * @param address The address where the prefix "spotify:track:" starts 136 | * @return the track id without the prefix "spotify:track:" 137 | */ 138 | private String readTrackId(long address) { 139 | return this.readString(address + 14, 22); 140 | } 141 | 142 | /** 143 | * Read the track id from the memory. 144 | * 145 | * @return the track id without the prefix "spotify:track:" 146 | */ 147 | public String readTrackId() { 148 | return this.readTrackId(this.addressTrackId); 149 | } 150 | 151 | public PlaybackAccessor getPlaybackAccessor() { 152 | return this.playbackAccessor; 153 | } 154 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /rust/src/lib.rs: -------------------------------------------------------------------------------- 1 | use jni::objects::JClass; 2 | use jni::sys::{jint, jlong}; 3 | use jni::JNIEnv; 4 | use std::ffi::CString; 5 | use std::os::raw::c_char; 6 | 7 | use futures::executor::block_on; 8 | use windows::Win32::Foundation::E_FAIL; 9 | use windows::{core::*, Media::Control::*}; 10 | 11 | mod session; 12 | pub use session::get_spotify_session; 13 | 14 | #[no_mangle] 15 | pub extern "system" fn isSpotifyAvailable(_env: JNIEnv, _class: JClass) -> jint { 16 | let result = std::panic::catch_unwind(|| { 17 | block_on(async { 18 | match get_spotify_session() { 19 | Ok(Some(_session)) => Ok(1), 20 | Ok(None) => Ok(0), 21 | Err(_) => Err(()), 22 | } 23 | }) 24 | }); 25 | 26 | match result { 27 | Ok(Ok(val)) => val, 28 | _ => 0, 29 | } 30 | } 31 | 32 | #[no_mangle] 33 | pub extern "system" fn getPlaybackPosition(_env: JNIEnv, _class: JClass) -> jlong { 34 | let result = std::panic::catch_unwind(|| { 35 | block_on(async { 36 | match get_spotify_session() { 37 | Ok(Some(session)) => { 38 | let timeline = session.GetTimelineProperties()?; 39 | let position = timeline.Position()?; 40 | Ok((position.Duration / 10_000) as jlong) 41 | } 42 | _ => Err(Error::new(E_FAIL, "Spotify session not available")), 43 | } 44 | }) 45 | }); 46 | 47 | match result { 48 | Ok(Ok(position)) => position, 49 | _ => -1, 50 | } 51 | } 52 | 53 | #[no_mangle] 54 | pub extern "system" fn getTrackDuration(_env: JNIEnv, _class: JClass) -> jlong { 55 | let result = std::panic::catch_unwind(|| { 56 | block_on(async { 57 | match get_spotify_session()? { 58 | Some(session) => { 59 | let timeline = session.GetTimelineProperties()?; 60 | let duration = timeline.EndTime()?.Duration; 61 | Ok((duration / 10_000) as jlong) 62 | } 63 | _ => Err(Error::new(E_FAIL, "Spotify session not found")), 64 | } 65 | }) 66 | }); 67 | 68 | match result { 69 | Ok(Ok(duration)) => duration, 70 | _ => -1, 71 | } 72 | } 73 | 74 | #[no_mangle] 75 | pub extern "system" fn getTrackTitle(_env: JNIEnv, _class: JClass) -> *const c_char { 76 | let result = std::panic::catch_unwind(|| { 77 | block_on(async { 78 | match get_spotify_session()? { 79 | Some(session) => { 80 | let props = session.TryGetMediaPropertiesAsync()?.get()?; 81 | let title = props.Title()?.to_string(); 82 | // Safely create CString, fallback to empty string if null bytes present 83 | Ok(CString::new(title).unwrap_or_default().into_raw()) 84 | } 85 | _ => Err(Error::new(E_FAIL, "Spotify session not found")), 86 | } 87 | }) 88 | }); 89 | 90 | match result { 91 | Ok(Ok(ptr)) => ptr, 92 | _ => std::ptr::null(), 93 | } 94 | } 95 | 96 | #[no_mangle] 97 | pub extern "system" fn getArtistName(_env: JNIEnv, _class: JClass) -> *const c_char { 98 | let result = std::panic::catch_unwind(|| { 99 | block_on(async { 100 | match get_spotify_session()? { 101 | Some(session) => { 102 | let props = session.TryGetMediaPropertiesAsync()?.get()?; 103 | let artist = props.Artist()?.to_string(); 104 | Ok(CString::new(artist).unwrap_or_default().into_raw()) 105 | } 106 | _ => Err(Error::new(E_FAIL, "Spotify session not found")), 107 | } 108 | }) 109 | }); 110 | 111 | match result { 112 | Ok(Ok(ptr)) => ptr, 113 | _ => std::ptr::null(), 114 | } 115 | } 116 | 117 | #[no_mangle] 118 | pub extern "system" fn isPlaying(_env: JNIEnv, _class: JClass) -> jint { 119 | let result = std::panic::catch_unwind(|| { 120 | block_on(async { 121 | match get_spotify_session()? { 122 | Some(session) => { 123 | let playback_info = session.GetPlaybackInfo()?; 124 | let status = playback_info.PlaybackStatus()?; 125 | Ok( 126 | (status == GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing) 127 | as jint, 128 | ) 129 | } 130 | _ => Err(Error::new(E_FAIL, "Spotify session not found")), 131 | } 132 | }) 133 | }); 134 | 135 | match result { 136 | Ok(Ok(val)) => val, 137 | _ => -1, 138 | } 139 | } 140 | 141 | #[no_mangle] 142 | pub extern "system" fn getCoverArt(out_ptr: *mut *mut u8, out_len: *mut usize) -> i32 { 143 | if out_ptr.is_null() || out_len.is_null() { 144 | return 0; // false 145 | } 146 | let result = std::panic::catch_unwind(|| { 147 | block_on(async { 148 | match get_spotify_session()? { 149 | Some(session) => { 150 | let props = session.TryGetMediaPropertiesAsync()?.get()?; 151 | let thumbnail = props.Thumbnail()?; 152 | let stream = thumbnail.OpenReadAsync()?.get()?; 153 | let size = stream.Size()?; 154 | 155 | if size == 0 { 156 | return Err(Error::new(E_FAIL, "Cover art size is zero")); 157 | } 158 | 159 | use windows::Storage::Streams::DataReader; 160 | let reader = DataReader::CreateDataReader(&stream)?; 161 | reader.LoadAsync(size as u32)?.get()?; 162 | 163 | let mut buffer = vec![0u8; size as usize]; 164 | reader.ReadBytes(&mut buffer)?; 165 | Ok(buffer) 166 | } 167 | _ => Err(Error::empty()), 168 | } 169 | }) 170 | }); 171 | 172 | match result { 173 | Ok(Ok(buffer)) => unsafe { 174 | let len = buffer.len(); 175 | let ptr = libc::malloc(len); 176 | if ptr.is_null() { 177 | return 0; // false 178 | } 179 | std::ptr::copy_nonoverlapping(buffer.as_ptr(), ptr as *mut u8, len); 180 | *out_ptr = ptr as *mut u8; 181 | *out_len = len; 182 | 1 // true 183 | }, 184 | _ => { 185 | 0 // false 186 | } 187 | } 188 | } 189 | 190 | #[no_mangle] 191 | pub extern "system" fn freeString(s: *mut c_char) { 192 | if !s.is_null() { 193 | unsafe { 194 | let _ = CString::from_raw(s); 195 | } 196 | } 197 | } 198 | 199 | #[no_mangle] 200 | pub extern "system" fn freeCoverArt(ptr: *mut u8) { 201 | unsafe { 202 | if !ptr.is_null() { 203 | libc::free(ptr as *mut _); 204 | } 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/linux/api/model/Variant.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.linux.api.model; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | /** 7 | * DBus variant parser 8 | *

9 | * This class is used to parse DBus variant responses. 10 | * It stores the data in key=value pairs. 11 | * A value can be a primitive or another variant to represent nested data. 12 | * 13 | * @author LabyStudio 14 | */ 15 | public class Variant { 16 | 17 | private final String sig; 18 | private final Object value; 19 | 20 | public Variant(String sig, Object value) { 21 | this.sig = sig; 22 | this.value = value; 23 | } 24 | 25 | /** 26 | * Get the key of the variant 27 | * If the variant key is not given, the key will be "variant" 28 | * 29 | * @return Key of the variant 30 | */ 31 | public String getSig() { 32 | return this.sig; 33 | } 34 | 35 | /** 36 | * Get the value of the variant 37 | * The value can be a primitive or another variant to represent nested data. 38 | *

39 | * If the value is a primitive, it can be a String, Integer, Long, Double or Boolean. 40 | * If the value is a variant, it can be a String[], Integer[], Long[], Double[], Boolean[] or Variant[]. 41 | * 42 | * @param Expected type 43 | * @return Value of the variant 44 | */ 45 | @SuppressWarnings("unchecked") 46 | public T getValue() { 47 | return (T) this.value; 48 | } 49 | 50 | @Override 51 | public String toString() { 52 | return this.sig + ":" + this.value; 53 | } 54 | 55 | public static Variant parse(String raw) { 56 | // Cleanup 57 | raw = raw.trim().replace("\n", ""); 58 | while (raw.contains(" ")) { 59 | raw = raw.replace(" ", " "); 60 | } 61 | return new Variant("variant", parse0(raw)); 62 | } 63 | 64 | private static Object parse0(String raw) { 65 | String[] segments = raw.split(" ", 2); 66 | if (segments.length != 2) { 67 | throw new IllegalArgumentException("Invalid variant: " + raw); 68 | } 69 | 70 | String signature = segments[0]; 71 | String payload = segments[1]; 72 | 73 | if (signature.startsWith("variant")) { 74 | String[] variantSegments = payload.split(" ", 2); 75 | return parseVariant(variantSegments[0], variantSegments[1]); 76 | } else if (signature.startsWith("dict")) { 77 | return parseDict(payload); 78 | } else { 79 | throw new IllegalArgumentException("Invalid variant signature: " + signature); 80 | } 81 | } 82 | 83 | @SuppressWarnings("SuspiciousToArrayCall") 84 | private static Object parseVariant(String type, String value) { 85 | switch (type) { 86 | case "array": { 87 | String collection = value.substring(1, value.length() - 1).trim(); 88 | 89 | List list = new ArrayList<>(); 90 | StringBuilder buffer = new StringBuilder(); 91 | boolean nested = false; 92 | boolean escaped = false; 93 | boolean primitive = false; 94 | String tempType = null; 95 | 96 | for (int i = 0; i < collection.length(); i++) { 97 | char c = collection.charAt(i); 98 | 99 | if (c == '"') { 100 | escaped = !escaped; 101 | } 102 | if (!escaped) { 103 | if (c == '(') { 104 | nested = true; 105 | } 106 | if (c == ')') { 107 | nested = false; 108 | list.add(parseDict(buffer + ")")); 109 | buffer = new StringBuilder(); 110 | continue; 111 | } 112 | if (!nested && c == ' ' && buffer.length() > 0) { 113 | String keyword = buffer.toString().trim(); 114 | buffer = new StringBuilder(); 115 | 116 | if (tempType == null) { 117 | if (!keyword.equals("dict")) { 118 | tempType = keyword; 119 | } 120 | } else { 121 | Object variant = parseVariant(tempType, keyword); 122 | if (!(variant instanceof Variant)) { 123 | primitive = true; 124 | } 125 | list.add(variant); 126 | tempType = null; 127 | } 128 | } 129 | } 130 | buffer.append(c); 131 | } 132 | 133 | if (tempType != null) { 134 | String keyword = buffer.toString().trim(); 135 | Object variant = parseVariant(tempType, keyword); 136 | if (!(variant instanceof Variant)) { 137 | primitive = true; 138 | } 139 | list.add(variant); 140 | } 141 | 142 | if (primitive) { 143 | if (list.get(0) instanceof String) { 144 | return list.toArray(new String[0]); 145 | } 146 | return list.toArray(); 147 | } 148 | 149 | return list.toArray(new Variant[0]); 150 | } 151 | case "string": { 152 | return value.substring(1, value.length() - 1); 153 | } 154 | case "int32": { 155 | return Integer.parseInt(value); 156 | } 157 | case "uint32": { 158 | return Integer.parseUnsignedInt(value); 159 | } 160 | case "int64": { 161 | return Long.parseLong(value); 162 | } 163 | case "uint64": { 164 | return Long.parseUnsignedLong(value); 165 | } 166 | case "double": { 167 | return Double.parseDouble(value); 168 | } 169 | default: { 170 | return value; 171 | } 172 | } 173 | } 174 | 175 | private static Variant parseDict(String payload) { 176 | String sigType = null; 177 | String sig = null; 178 | 179 | StringBuilder buffer = new StringBuilder(); 180 | boolean nested = false; 181 | boolean escaped = false; 182 | 183 | for (int i = 0; i < payload.length(); i++) { 184 | char c = payload.charAt(i); 185 | 186 | if (c == '"') { 187 | escaped = !escaped; 188 | } 189 | if (!escaped) { 190 | if (c == '(') { 191 | nested = true; 192 | continue; 193 | } 194 | if (c == ')') { 195 | nested = false; 196 | continue; 197 | } 198 | if (nested && c == ' ') { 199 | if (buffer.length() == 0) { 200 | continue; 201 | } 202 | 203 | if (sigType == null) { 204 | sigType = buffer.toString(); 205 | buffer = new StringBuilder(); 206 | 207 | if (!sigType.equals("string")) { 208 | throw new IllegalArgumentException("Invalid dict sig type: " + sigType); 209 | } 210 | } else if (sig == null) { 211 | sig = (String) Variant.parseVariant(sigType, buffer.toString().trim()); 212 | buffer = new StringBuilder(); 213 | } 214 | } 215 | } 216 | 217 | if (nested) { 218 | buffer.append(c); 219 | } 220 | } 221 | return new Variant(sig, parse0(buffer.toString().trim())); 222 | } 223 | 224 | 225 | } 226 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/api/WinApi.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows.api; 2 | 3 | import com.sun.jna.Native; 4 | import com.sun.jna.Pointer; 5 | import com.sun.jna.platform.win32.BaseTSD; 6 | import com.sun.jna.platform.win32.User32; 7 | import com.sun.jna.platform.win32.WinDef; 8 | import com.sun.jna.platform.win32.WinNT; 9 | import com.sun.jna.platform.win32.WinUser; 10 | import com.sun.jna.ptr.IntByReference; 11 | import de.labystudio.spotifyapi.platform.windows.api.jna.Kernel32; 12 | import de.labystudio.spotifyapi.platform.windows.api.jna.Psapi; 13 | import de.labystudio.spotifyapi.platform.windows.api.jna.Tlhelp32; 14 | 15 | import java.util.ArrayList; 16 | import java.util.Arrays; 17 | import java.util.HashMap; 18 | import java.util.List; 19 | import java.util.Map; 20 | import java.util.concurrent.atomic.AtomicReference; 21 | 22 | /** 23 | * Windows API Utilities 24 | * 25 | * @author LabyStudio 26 | */ 27 | public interface WinApi { 28 | 29 | int PROCESS_VM_READ = 0x0010; 30 | int PROCESS_VM_WRITE = 0x0020; 31 | int PROCESS_VM_OPERATION = 0x0008; 32 | 33 | int VK_VOLUME_MUTE = 0xAD; 34 | int VK_VOLUME_DOWN = 0xAE; 35 | int VK_VOLUME_UP = 0xAF; 36 | int VK_MEDIA_NEXT_TRACK = 0xB0; 37 | int VK_MEDIA_PREV_TRACK = 0xB1; 38 | int VK_MEDIA_STOP = 0xB2; 39 | int VK_MEDIA_PLAY_PAUSE = 0xB3; 40 | 41 | default int getProcessIdByName(String exeName) { 42 | Kernel32 kernel = Kernel32.INSTANCE; 43 | 44 | WinNT.HANDLE snapshot = kernel.CreateToolhelp32Snapshot(Tlhelp32.TH32CS_SNAPPROCESS, new WinDef.DWORD(0)); 45 | Tlhelp32.PROCESSENTRY32.ByReference processEntry = new Tlhelp32.PROCESSENTRY32.ByReference(); 46 | 47 | while (kernel.Process32Next(snapshot, processEntry)) { 48 | if (Native.toString(processEntry.szExeFile).equals(exeName)) { 49 | int processId = processEntry.th32ProcessID.intValue(); 50 | kernel.CloseHandle(snapshot); 51 | return processId; 52 | } 53 | } 54 | kernel.CloseHandle(snapshot); 55 | return -1; 56 | } 57 | 58 | default List getProcessIdsByName(String exeName) { 59 | List processIds = new ArrayList<>(); 60 | Kernel32 kernel = Kernel32.INSTANCE; 61 | 62 | WinNT.HANDLE snapshot = kernel.CreateToolhelp32Snapshot(Tlhelp32.TH32CS_SNAPPROCESS, new WinDef.DWORD(0)); 63 | Tlhelp32.PROCESSENTRY32.ByReference processEntry = new Tlhelp32.PROCESSENTRY32.ByReference(); 64 | 65 | while (kernel.Process32Next(snapshot, processEntry)) { 66 | if (Native.toString(processEntry.szExeFile).equals(exeName)) { 67 | processIds.add(processEntry.th32ProcessID.intValue()); 68 | } 69 | } 70 | kernel.CloseHandle(snapshot); 71 | return processIds; 72 | } 73 | 74 | default WinNT.HANDLE openProcessHandle(int processId) { 75 | Kernel32 kernel = Kernel32.INSTANCE; 76 | return kernel.OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION, false, processId); 77 | } 78 | 79 | default WinDef.HWND openWindow(int processId) { 80 | return this.openWindow(processId, hWnd -> true); 81 | } 82 | 83 | default WinDef.HWND openWindow(int processId, WindowCondition condition) { 84 | AtomicReference window = new AtomicReference<>(); 85 | 86 | // Iterate over all windows and find the one with the given processId 87 | User32.INSTANCE.EnumWindows((hWnd, data) -> { 88 | 89 | // Get the process id of the window 90 | IntByReference reference = new IntByReference(); 91 | User32.INSTANCE.GetWindowThreadProcessId(hWnd, reference); 92 | 93 | // Check if the window belongs to the process 94 | if (reference.getValue() == processId && this.isWindowVisible(hWnd) && condition.test(hWnd)) { 95 | window.set(hWnd); 96 | return false; 97 | } 98 | 99 | return true; 100 | }, null); 101 | 102 | // Return the window handle 103 | return window.get(); 104 | } 105 | 106 | default boolean isWindowVisible(WinDef.HWND hWnd) { 107 | return User32.INSTANCE.IsWindowVisible(hWnd); 108 | } 109 | 110 | default String getWindowTitle(WinDef.HWND window) { 111 | char[] buffer = new char[512]; 112 | int length = User32.INSTANCE.GetWindowText(window, buffer, buffer.length); 113 | return Native.toString(Arrays.copyOf(buffer, length)); 114 | } 115 | 116 | default Map getModules(WinNT.HANDLE handle) { 117 | Map modules = new HashMap<>(); 118 | 119 | // Iterate over all modules 120 | Pointer[] moduleHandles = this.getModuleHandles(handle); 121 | for (Pointer moduleHandle : moduleHandles) { 122 | // Get module name 123 | char[] characters = new char[1024]; 124 | int length = Psapi.INSTANCE.GetModuleBaseName(handle, moduleHandle, characters, characters.length); 125 | String moduleName = new String(characters, 0, length); 126 | 127 | // Get module info 128 | Psapi.ModuleInfo moduleInfo = new Psapi.ModuleInfo(); 129 | Psapi.INSTANCE.GetModuleInformation(handle, moduleHandle, moduleInfo, moduleInfo.size()); 130 | modules.put(moduleName, moduleInfo); 131 | } 132 | 133 | return modules; 134 | } 135 | 136 | default Psapi.ModuleInfo getModuleInfo(WinNT.HANDLE handle, String moduleName) { 137 | Pointer[] moduleHandles = this.getModuleHandles(handle); 138 | 139 | // Iterate over all modules 140 | for (Pointer moduleHandle : moduleHandles) { 141 | // Get module name 142 | char[] characters = new char[1024]; 143 | int length = Psapi.INSTANCE.GetModuleBaseName(handle, moduleHandle, characters, characters.length); 144 | String entryModuleName = new String(characters, 0, length); 145 | 146 | // Compare with the name we are looking for 147 | if (entryModuleName.equals(moduleName)) { 148 | 149 | // Get module info 150 | Psapi.ModuleInfo moduleInfo = new Psapi.ModuleInfo(); 151 | Psapi.INSTANCE.GetModuleInformation(handle, moduleHandle, moduleInfo, moduleInfo.size()); 152 | 153 | return moduleInfo; 154 | } 155 | } 156 | 157 | return null; 158 | } 159 | 160 | default Pointer[] getModuleHandles(WinNT.HANDLE handle) { 161 | IntByReference amountRef = new IntByReference(); 162 | 163 | // Get the list of modules 164 | Pointer[] moduleHandles = new Pointer[2048]; 165 | if (!Psapi.INSTANCE.EnumProcessModulesEx( 166 | handle, 167 | moduleHandles, 168 | moduleHandles.length, 169 | amountRef, 170 | Psapi.ModuleFilter.ALL 171 | )) { 172 | throw new RuntimeException("Failed to get module list: ERROR " + Kernel32.INSTANCE.GetLastError()); 173 | } 174 | 175 | int amount = amountRef.getValue(); 176 | if (amount == 0) { 177 | throw new RuntimeException("No modules found"); 178 | } 179 | 180 | return Arrays.copyOf(moduleHandles, amount); 181 | } 182 | 183 | default void pressKey(int keyCode) { 184 | WinUser.INPUT input = new WinUser.INPUT(); 185 | 186 | input.type = new WinDef.DWORD(WinUser.INPUT.INPUT_KEYBOARD); 187 | input.input.setType("ki"); 188 | input.input.ki.wVk = new WinDef.WORD(keyCode); // Key code 189 | input.input.ki.wScan = new WinDef.WORD(0); // Hardware scan code 190 | input.input.ki.time = new WinDef.DWORD(0); // Timestamp (System default) 191 | input.input.ki.dwExtraInfo = new BaseTSD.ULONG_PTR(0); 192 | 193 | // Press the key 194 | input.input.ki.dwFlags = new WinDef.DWORD(0); // Key down 195 | User32.INSTANCE.SendInput(new WinDef.DWORD(1), new WinUser.INPUT[]{input}, input.size()); 196 | 197 | // Release the key 198 | input.input.ki.dwFlags = new WinDef.DWORD(2); // Key up 199 | User32.INSTANCE.SendInput(new WinDef.DWORD(1), new WinUser.INPUT[]{input}, input.size()); 200 | 201 | } 202 | 203 | public interface WindowCondition { 204 | boolean test(WinDef.HWND window); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/platform/windows/WinSpotifyAPI.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.platform.windows; 2 | 3 | import de.labystudio.spotifyapi.SpotifyListener; 4 | import de.labystudio.spotifyapi.model.MediaKey; 5 | import de.labystudio.spotifyapi.model.Track; 6 | import de.labystudio.spotifyapi.platform.AbstractTickSpotifyAPI; 7 | import de.labystudio.spotifyapi.platform.windows.api.WinApi; 8 | import de.labystudio.spotifyapi.platform.windows.api.jna.WindowsMediaControl; 9 | import de.labystudio.spotifyapi.platform.windows.api.playback.PlaybackAccessor; 10 | import de.labystudio.spotifyapi.platform.windows.api.spotify.SpotifyProcess; 11 | 12 | import javax.imageio.ImageIO; 13 | import java.awt.image.BufferedImage; 14 | import java.io.ByteArrayInputStream; 15 | import java.io.IOException; 16 | import java.io.InputStream; 17 | import java.nio.file.Files; 18 | import java.nio.file.Path; 19 | import java.nio.file.StandardCopyOption; 20 | import java.util.Objects; 21 | 22 | /** 23 | * Windows implementation of the SpotifyAPI. 24 | * The implementation uses the Windows API to access the memory of the Spotify process. 25 | * The currently playing track name and artist are read from the Windows title bar. 26 | * 27 | * @author LabyStudio 28 | */ 29 | public class WinSpotifyAPI extends AbstractTickSpotifyAPI { 30 | 31 | private static WindowsMediaControl mediaControl; 32 | 33 | private SpotifyProcess process; 34 | 35 | private Track currentTrack; 36 | private int currentPosition = -1; 37 | private boolean hasTrackPosition = false; 38 | private boolean isPlaying; 39 | 40 | private long lastTimePositionUpdated; 41 | private long prevLastReportedPosition = -1; 42 | 43 | @Override 44 | protected void onInitialized() { 45 | try { 46 | this.initializeMediaControl(this.configuration.getNativesDirectory()); 47 | } catch (Throwable e) { 48 | e.printStackTrace(); 49 | } 50 | } 51 | 52 | private void initializeMediaControl(Path nativesDirectory) throws IOException { 53 | if (mediaControl != null) { 54 | return; // Already initialized 55 | } 56 | 57 | boolean is64Bit = System.getProperty("os.arch").contains("64"); 58 | 59 | String path = "/natives/windows-x" + (is64Bit ? 64 : 86) + "/windowsmediacontrol.dll"; 60 | try (InputStream nativesStream = SpotifyProcess.class.getResourceAsStream(path)) { 61 | if (nativesStream == null) { 62 | throw new IOException("Could not find native library: " + path); 63 | } 64 | 65 | Path nativeLibraryPath = nativesDirectory.resolve("windowsmediacontrol.dll"); 66 | try { 67 | // Ensure the natives directory exists 68 | if (!Files.exists(nativesDirectory)) { 69 | Files.createDirectories(nativesDirectory); 70 | } 71 | 72 | // Extract the native library to the specified directory 73 | Files.copy(nativesStream, nativeLibraryPath, StandardCopyOption.REPLACE_EXISTING); 74 | } catch (IOException e) { 75 | throw new IOException("Failed to copy native library to " + nativeLibraryPath, e); 76 | } 77 | 78 | // Load the native library 79 | mediaControl = WindowsMediaControl.loadLibrary(nativeLibraryPath); 80 | } 81 | } 82 | 83 | /** 84 | * Updates the current track, position and playback state. 85 | * If the process is not connected, it will try to connect to the Spotify process. 86 | */ 87 | protected void onTick() { 88 | if (!this.isConnected()) { 89 | // Connect 90 | this.process = new SpotifyProcess(mediaControl); 91 | 92 | // Fire on connect 93 | this.listeners.forEach(SpotifyListener::onConnect); 94 | } 95 | 96 | // Read track id and check if track id is valid 97 | String trackId = this.process.readTrackId(); 98 | if (!Track.isTrackIdValid(trackId)) { 99 | throw new IllegalStateException("Invalid track ID: " + trackId); 100 | } 101 | 102 | // Update playback state 103 | PlaybackAccessor accessor = this.process.getPlaybackAccessor(); 104 | accessor.updatePlayback(); 105 | 106 | // Handle track changes 107 | String currentTrackId = this.currentTrack == null ? null : this.currentTrack.getId(); 108 | if (!Objects.equals(trackId, currentTrackId)) { 109 | // Update track information 110 | accessor.updateTrack(); 111 | 112 | String trackTitle = accessor.getTitle(); 113 | String trackArtist = accessor.getArtist(); 114 | 115 | // Check if title or artist changed (The Windows Media API is slow in updating the track information) 116 | String currentTrackTitle = this.currentTrack == null ? null : this.currentTrack.getName(); 117 | String currentTrackArtist = this.currentTrack == null ? null : this.currentTrack.getArtist(); 118 | if (!Objects.equals(trackTitle, currentTrackTitle) 119 | || !Objects.equals(trackArtist, currentTrackArtist)) { 120 | BufferedImage coverArt = this.toBufferedImage(accessor.getCoverArt()); 121 | 122 | Track track = new Track( 123 | trackId, 124 | trackTitle, 125 | trackArtist, 126 | accessor.getLength(), 127 | coverArt 128 | ); 129 | this.currentTrack = track; 130 | 131 | // Fire on track changed 132 | this.listeners.forEach(listener -> listener.onTrackChanged(track)); 133 | } 134 | } 135 | 136 | // Handle is playing changes 137 | boolean isPlaying = accessor.isPlaying(); 138 | if (isPlaying != this.isPlaying) { 139 | this.isPlaying = isPlaying; 140 | 141 | // Fire on play back changed 142 | this.listeners.forEach(listener -> listener.onPlayBackChanged(isPlaying)); 143 | } 144 | 145 | if (accessor.hasTrackPosition()) { 146 | this.hasTrackPosition = true; 147 | 148 | int lastReportedPosition = accessor.getPosition(); 149 | 150 | if (this.prevLastReportedPosition != lastReportedPosition) { 151 | this.prevLastReportedPosition = lastReportedPosition; 152 | 153 | // Get the daemon position (Last reported position + relative time) 154 | int expectedPosition = this.getPosition(); 155 | 156 | // Compare if the expected position based on time and the last reported position are close enough 157 | boolean seeked = Math.abs(lastReportedPosition - expectedPosition) > TICK_INTERVAL; 158 | 159 | this.currentPosition = lastReportedPosition; 160 | this.lastTimePositionUpdated = System.currentTimeMillis(); 161 | 162 | // Fire on position changed 163 | if (seeked) { 164 | this.listeners.forEach(listener -> listener.onPositionChanged(this.currentPosition)); 165 | } 166 | } 167 | } else { 168 | this.currentPosition = -1; 169 | this.hasTrackPosition = false; 170 | this.lastTimePositionUpdated = System.currentTimeMillis(); 171 | } 172 | 173 | // Fire keep alive 174 | this.listeners.forEach(SpotifyListener::onSync); 175 | } 176 | 177 | @Override 178 | public Track getTrack() { 179 | return this.currentTrack; 180 | } 181 | 182 | @Override 183 | public int getPosition() { 184 | if (!this.hasPosition()) { 185 | throw new IllegalStateException("Position is not known yet. Pause the song for a second and try again."); 186 | } 187 | 188 | if (this.isPlaying) { 189 | // Interpolate position 190 | long timePassed = System.currentTimeMillis() - this.lastTimePositionUpdated; 191 | long interpolatedPosition = this.currentPosition + timePassed; 192 | 193 | if (this.hasTrack()) { 194 | return (int) Math.min(interpolatedPosition, this.currentTrack.getLength()); 195 | } else { 196 | return (int) interpolatedPosition; 197 | } 198 | } else { 199 | return this.currentPosition; 200 | } 201 | } 202 | 203 | @Override 204 | public boolean hasPosition() { 205 | if (!this.isConnected()) { 206 | return false; 207 | } 208 | return this.hasTrackPosition; 209 | } 210 | 211 | @Override 212 | public void pressMediaKey(MediaKey mediaKey) { 213 | if (!this.isConnected()) { 214 | throw new IllegalStateException("Spotify is not connected"); 215 | } 216 | 217 | switch (mediaKey) { 218 | case NEXT: 219 | this.process.pressKey(WinApi.VK_MEDIA_NEXT_TRACK); 220 | break; 221 | case PREV: 222 | this.process.pressKey(WinApi.VK_MEDIA_PREV_TRACK); 223 | break; 224 | case PLAY_PAUSE: 225 | this.process.pressKey(WinApi.VK_MEDIA_PLAY_PAUSE); 226 | break; 227 | } 228 | 229 | // Update state immediately 230 | this.onInternalTick(); 231 | } 232 | 233 | @Override 234 | public boolean isPlaying() { 235 | return this.isPlaying; 236 | } 237 | 238 | @Override 239 | public boolean isConnected() { 240 | return this.process != null && this.process.isOpen(); 241 | } 242 | 243 | @Override 244 | public void stop() { 245 | super.stop(); 246 | 247 | if (this.process != null) { 248 | this.process.close(); 249 | this.process = null; 250 | } 251 | 252 | this.currentTrack = null; 253 | this.currentPosition = -1; 254 | this.hasTrackPosition = false; 255 | this.isPlaying = false; 256 | this.lastTimePositionUpdated = 0; 257 | this.prevLastReportedPosition = -1; 258 | } 259 | 260 | private BufferedImage toBufferedImage(byte[] data) { 261 | if (data == null || data.length == 0) { 262 | return null; // No cover art available 263 | } 264 | try { 265 | return ImageIO.read(new ByteArrayInputStream(data)); 266 | } catch (Throwable e) { 267 | e.printStackTrace(); 268 | return null; // Failed to load cover art 269 | } 270 | } 271 | 272 | } 273 | -------------------------------------------------------------------------------- /src/main/java/de/labystudio/spotifyapi/open/OpenSpotifyAPI.java: -------------------------------------------------------------------------------- 1 | package de.labystudio.spotifyapi.open; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | import com.google.gson.JsonObject; 6 | import com.google.gson.stream.JsonReader; 7 | import de.labystudio.spotifyapi.model.Track; 8 | import de.labystudio.spotifyapi.open.model.AccessTokenResponse; 9 | import de.labystudio.spotifyapi.open.model.track.OpenTrack; 10 | import de.labystudio.spotifyapi.open.totp.TOTP; 11 | import de.labystudio.spotifyapi.open.totp.gson.SecretDeserializer; 12 | import de.labystudio.spotifyapi.open.totp.gson.SecretSerializer; 13 | import de.labystudio.spotifyapi.open.totp.model.Secret; 14 | import de.labystudio.spotifyapi.open.totp.provider.SecretProvider; 15 | 16 | import javax.imageio.ImageIO; 17 | import javax.net.ssl.HttpsURLConnection; 18 | import java.awt.image.BufferedImage; 19 | import java.io.BufferedReader; 20 | import java.io.IOException; 21 | import java.io.InputStream; 22 | import java.io.InputStreamReader; 23 | import java.net.HttpURLConnection; 24 | import java.net.URL; 25 | import java.nio.charset.StandardCharsets; 26 | import java.util.concurrent.Executor; 27 | import java.util.concurrent.Executors; 28 | import java.util.function.Consumer; 29 | 30 | /** 31 | * OpenSpotify REST API. 32 | * Implements the functionality to request the image of a Spotify track. 33 | * 34 | * @author LabyStudio 35 | */ 36 | public class OpenSpotifyAPI { 37 | 38 | public static final Gson GSON = new GsonBuilder() 39 | .registerTypeAdapter(Secret.class, new SecretDeserializer()) 40 | .registerTypeAdapter(Secret.class, new SecretSerializer()) 41 | .create(); 42 | 43 | public static final String USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537." + (int) (Math.random() * 90); 44 | public static final String URL_API_GEN_ACCESS_TOKEN = "https://open.spotify.com/api/token?reason=%s&productType=web-player&totp=%s&totpServer=%s&totpVer=%s"; 45 | 46 | public static final String URL_API_TRACKS = "https://api.spotify.com/v1/tracks/%s"; 47 | public static final String URL_API_SERVER_TIME = "https://open.spotify.com/api/server-time"; 48 | 49 | private final Executor executor = Executors.newSingleThreadExecutor(); 50 | 51 | private final Cache imageCache = new Cache<>(10); 52 | private final Cache openTrackCache = new Cache<>(100); 53 | 54 | private final SecretProvider secretProvider; 55 | 56 | private AccessTokenResponse accessTokenResponse; 57 | 58 | public OpenSpotifyAPI(SecretProvider secretProvider) { 59 | this.secretProvider = secretProvider; 60 | } 61 | 62 | /** 63 | * Generate an access token asynchronously for the open spotify api 64 | */ 65 | private void generateAccessTokenAsync(Consumer callback) { 66 | this.executor.execute(() -> { 67 | try { 68 | // Generate access token 69 | callback.accept(this.generateAccessToken()); 70 | } catch (Exception error) { 71 | error.printStackTrace(); 72 | } 73 | }); 74 | } 75 | 76 | /** 77 | * Request server time of Spotify for time-based one time password 78 | * 79 | * @return server time in seconds 80 | */ 81 | public long requestServerTime() throws IOException { 82 | // Get server time 83 | URL url = new URL(URL_API_SERVER_TIME); 84 | HttpURLConnection conn = (HttpURLConnection) url.openConnection(); 85 | conn.setRequestMethod("GET"); 86 | conn.setRequestProperty("Host", "open.spotify.com"); 87 | conn.setRequestProperty("User-Agent", USER_AGENT); 88 | conn.setRequestProperty("Accept", "application/json"); 89 | 90 | BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); 91 | String response = reader.readLine(); 92 | reader.close(); 93 | 94 | JsonObject obj = GSON.fromJson(response, JsonObject.class); 95 | return obj.get("serverTime").getAsLong(); 96 | } 97 | 98 | /** 99 | * Generate an access token for the open spotify api 100 | */ 101 | private AccessTokenResponse generateAccessToken() throws IOException { 102 | Secret secret = this.secretProvider.getSecret(); 103 | if (secret == null) { 104 | throw new IOException("No TOTP secret provided"); 105 | } 106 | 107 | long serverTime = this.requestServerTime(); 108 | String totp = TOTP.generateOtp(secret.getSecretAsBytes(), serverTime, 30, 6); 109 | 110 | AccessTokenResponse response = this.getToken("transport", totp, secret.getVersion()); 111 | 112 | if (!this.hasValidAccessToken(response)) { 113 | response = this.getToken("init", totp, secret.getVersion()); 114 | } 115 | 116 | if (!this.hasValidAccessToken(response)) { 117 | throw new IOException("Could not generate access token"); 118 | } 119 | 120 | return response; 121 | } 122 | 123 | /** 124 | * Retrieve access token using totp 125 | */ 126 | private AccessTokenResponse getToken(String mode, String totp, int version) throws IOException { 127 | // Open connection 128 | String url = String.format(URL_API_GEN_ACCESS_TOKEN, mode, totp, totp, version); 129 | HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection(); 130 | connection.addRequestProperty("User-Agent", USER_AGENT); 131 | connection.addRequestProperty("referer", "https://open.spotify.com/"); 132 | connection.addRequestProperty("app-platform", "WebPlayer"); 133 | connection.setRequestProperty("Accept", "application/json"); 134 | 135 | int code = connection.getResponseCode(); 136 | if (code != HttpURLConnection.HTTP_OK) { 137 | InputStream errorStream = connection.getErrorStream(); 138 | if (errorStream != null) { 139 | JsonReader reader = new JsonReader(new InputStreamReader(errorStream)); 140 | JsonObject response = GSON.fromJson(reader, JsonObject.class); 141 | throw new IOException("Could not retrieve access token: " + response.toString()); 142 | } 143 | } 144 | 145 | // Read response 146 | JsonReader reader = new JsonReader(new InputStreamReader(connection.getInputStream())); 147 | return GSON.fromJson(reader, AccessTokenResponse.class); 148 | } 149 | 150 | /** 151 | * Request the cover image of the given track asynchronously. 152 | * If the track is already in the cache, it will be returned. 153 | * 154 | * @param track The track to lookup 155 | * @param callback Response with the buffered image track. It won't be called on an error. 156 | */ 157 | public void requestImageAsync(Track track, Consumer callback) { 158 | this.requestImageAsync(track.getId(), callback); 159 | } 160 | 161 | /** 162 | * Request the cover image of the given track asynchronously. 163 | * If the track is already in the cache, it will be returned. 164 | * 165 | * @param trackId The track id to lookup 166 | * @param callback Response with the buffered image track. It won't be called on an error. 167 | */ 168 | public void requestImageAsync(String trackId, Consumer callback) { 169 | if (!Track.isTrackIdValid(trackId)) { 170 | throw new IllegalArgumentException("Invalid track ID: " + trackId); 171 | } 172 | 173 | this.executor.execute(() -> { 174 | try { 175 | BufferedImage image = this.requestImage(trackId); 176 | if (image != null) { 177 | callback.accept(image); 178 | } 179 | } catch (Exception error) { 180 | error.printStackTrace(); 181 | } 182 | }); 183 | } 184 | 185 | /** 186 | * Request the cover image url of the given track asynchronously. 187 | * If the track is already in the cache, it will be returned. 188 | * 189 | * @param trackId The track id to lookup 190 | * @param callback Response with the image url of the track. It won't be called on an error. 191 | */ 192 | public void requestImageUrlAsync(String trackId, Consumer callback) { 193 | this.executor.execute(() -> { 194 | try { 195 | String imageUrl = this.requestImageUrl(trackId); 196 | if (imageUrl != null) { 197 | callback.accept(imageUrl); 198 | } 199 | } catch (Exception error) { 200 | error.printStackTrace(); 201 | } 202 | }); 203 | } 204 | 205 | /** 206 | * Request the track information of the given track asynchronously. 207 | * If the open track is already in the cache, it will be returned. 208 | * 209 | * @param track The track to lookup 210 | * @param callback Response with the open track. It won't be called on an error. 211 | */ 212 | public void requestOpenTrackAsync(Track track, Consumer callback) { 213 | this.requestOpenTrackAsync(track.getId(), callback); 214 | } 215 | 216 | /** 217 | * Request the track information of the given track asynchronously. 218 | * If the open track is already in the cache, it will be returned. 219 | * 220 | * @param trackId The track id to lookup 221 | * @param callback Response with the open track. It won't be called on an error. 222 | */ 223 | public void requestOpenTrackAsync(String trackId, Consumer callback) { 224 | this.executor.execute(() -> { 225 | try { 226 | OpenTrack openTrack = this.requestOpenTrack(trackId); 227 | if (openTrack != null) { 228 | callback.accept(openTrack); 229 | } 230 | } catch (Exception error) { 231 | error.printStackTrace(); 232 | } 233 | }); 234 | } 235 | 236 | /** 237 | * Request the cover image of the given track synchronously. 238 | * If the track is already in the cache, it will be returned. 239 | * 240 | * @param track The track to lookup 241 | * @return The buffered image of the track or null if it failed 242 | * @throws IOException if the request failed 243 | */ 244 | public BufferedImage requestImage(Track track) throws IOException { 245 | return this.requestImage(track.getId()); 246 | } 247 | 248 | /** 249 | * Request the cover image of the given track synchronously. 250 | * If the track is already in the cache, it will be returned. 251 | * 252 | * @param trackId The track id to lookup 253 | * @return The buffered image of the track or null if it failed 254 | * @throws IOException if the request failed 255 | */ 256 | public BufferedImage requestImage(String trackId) throws IOException { 257 | if (!Track.isTrackIdValid(trackId)) { 258 | throw new IllegalArgumentException("Invalid track ID: " + trackId); 259 | } 260 | 261 | // Try to get image from cache by track id 262 | BufferedImage cachedImage = this.imageCache.get(trackId); 263 | if (cachedImage != null) { 264 | return cachedImage; 265 | } 266 | 267 | // Request the image url 268 | String url = this.requestImageUrl(trackId); 269 | if (url == null) { 270 | return null; 271 | } 272 | 273 | // Download the image 274 | BufferedImage image = ImageIO.read(new URL(url)); 275 | if (image == null) { 276 | throw new IOException("Could not load image: " + url); 277 | } 278 | 279 | // Cache the image and return it 280 | this.imageCache.push(trackId, image); 281 | return image; 282 | } 283 | 284 | /** 285 | * Request the cover image url of the given track. 286 | * If the track is already in the cache, it will be returned. 287 | * 288 | * @param trackId The track id to lookup 289 | * @return The url of the track or null if it failed 290 | * @throws IOException if the request failed 291 | */ 292 | private String requestImageUrl(String trackId) throws IOException { 293 | // Request track information 294 | OpenTrack openTrack = this.requestOpenTrack(trackId); 295 | if (openTrack == null) { 296 | return null; 297 | } 298 | 299 | // Get largest image url 300 | return openTrack.album.images.get(0).url; 301 | } 302 | 303 | /** 304 | * Request the track information of the given track. 305 | * If the open track is already in the cache, it will be returned. 306 | * 307 | * @param track The track to lookup 308 | * @throws IOException if the request failed 309 | */ 310 | public OpenTrack requestOpenTrack(Track track) throws IOException { 311 | return this.requestOpenTrack(track.getId()); 312 | } 313 | 314 | /** 315 | * Request the track information of the given track. 316 | * If the open track is already in the cache, it will be returned. 317 | * 318 | * @param trackId The track id to lookup 319 | * @throws IOException if the request failed 320 | */ 321 | public OpenTrack requestOpenTrack(String trackId) throws IOException { 322 | OpenTrack cachedOpenTrack = this.openTrackCache.get(trackId); 323 | if (cachedOpenTrack != null) { 324 | return cachedOpenTrack; 325 | } 326 | 327 | // Create REST API url 328 | String url = String.format(URL_API_TRACKS, trackId); 329 | OpenTrack openTrack = this.request(url, OpenTrack.class, true); 330 | 331 | // Cache the open track and return it 332 | this.openTrackCache.push(trackId, openTrack); 333 | return openTrack; 334 | } 335 | 336 | /** 337 | * Request the open spotify api with the given url 338 | * It will try again once if it fails 339 | * 340 | * @param url The url to request 341 | * @param clazz The class to parse the response to 342 | * @param canGenerateNewAccessToken It will try again once if it fails 343 | * @param The type of the response 344 | * @return The parsed response 345 | * @throws IOException if the request failed 346 | */ 347 | public T request(String url, Class clazz, boolean canGenerateNewAccessToken) throws IOException { 348 | // Generate access token if not present 349 | if (this.accessTokenResponse == null) { 350 | this.accessTokenResponse = this.generateAccessToken(); 351 | } 352 | 353 | // Connect 354 | HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection(); 355 | connection.addRequestProperty("User-Agent", USER_AGENT); 356 | connection.addRequestProperty("referer", "https://open.spotify.com/"); 357 | connection.addRequestProperty("app-platform", "WebPlayer"); 358 | connection.addRequestProperty("origin", "https://open.spotify.com"); 359 | 360 | // Add access token 361 | if (this.accessTokenResponse != null) { 362 | connection.addRequestProperty("authorization", "Bearer " + this.accessTokenResponse.accessToken); 363 | } 364 | 365 | // Access token outdated 366 | if (connection.getResponseCode() / 100 != 2) { 367 | // Prevent infinite loop 368 | if (canGenerateNewAccessToken) { 369 | // Generate new access token 370 | this.accessTokenResponse = this.generateAccessToken(); 371 | 372 | // Try again 373 | return this.request(url, clazz, false); 374 | } else { 375 | // Request failed twice 376 | return null; 377 | } 378 | } 379 | 380 | // Read response 381 | JsonReader reader = new JsonReader(new InputStreamReader( 382 | connection.getInputStream(), 383 | StandardCharsets.UTF_8 384 | )); 385 | 386 | return GSON.fromJson(reader, clazz); 387 | } 388 | 389 | private boolean hasValidAccessToken(AccessTokenResponse response) { 390 | return response != null && response.accessToken != null && !response.accessToken.isEmpty(); 391 | } 392 | 393 | public Cache getImageCache() { 394 | return this.imageCache; 395 | } 396 | 397 | public Cache getOpenTrackCache() { 398 | return this.openTrackCache; 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /rust/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "bytes" 7 | version = "1.10.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 10 | 11 | [[package]] 12 | name = "cesu8" 13 | version = "1.1.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 16 | 17 | [[package]] 18 | name = "cfg-if" 19 | version = "1.0.1" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" 22 | 23 | [[package]] 24 | name = "combine" 25 | version = "4.6.7" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 28 | dependencies = [ 29 | "bytes", 30 | "memchr", 31 | ] 32 | 33 | [[package]] 34 | name = "futures" 35 | version = "0.3.31" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 38 | dependencies = [ 39 | "futures-channel", 40 | "futures-core", 41 | "futures-executor", 42 | "futures-io", 43 | "futures-sink", 44 | "futures-task", 45 | "futures-util", 46 | ] 47 | 48 | [[package]] 49 | name = "futures-channel" 50 | version = "0.3.31" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 53 | dependencies = [ 54 | "futures-core", 55 | "futures-sink", 56 | ] 57 | 58 | [[package]] 59 | name = "futures-core" 60 | version = "0.3.31" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 63 | 64 | [[package]] 65 | name = "futures-executor" 66 | version = "0.3.31" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 69 | dependencies = [ 70 | "futures-core", 71 | "futures-task", 72 | "futures-util", 73 | ] 74 | 75 | [[package]] 76 | name = "futures-io" 77 | version = "0.3.31" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 80 | 81 | [[package]] 82 | name = "futures-macro" 83 | version = "0.3.31" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 86 | dependencies = [ 87 | "proc-macro2", 88 | "quote", 89 | "syn", 90 | ] 91 | 92 | [[package]] 93 | name = "futures-sink" 94 | version = "0.3.31" 95 | source = "registry+https://github.com/rust-lang/crates.io-index" 96 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 97 | 98 | [[package]] 99 | name = "futures-task" 100 | version = "0.3.31" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 103 | 104 | [[package]] 105 | name = "futures-util" 106 | version = "0.3.31" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 109 | dependencies = [ 110 | "futures-channel", 111 | "futures-core", 112 | "futures-io", 113 | "futures-macro", 114 | "futures-sink", 115 | "futures-task", 116 | "memchr", 117 | "pin-project-lite", 118 | "pin-utils", 119 | "slab", 120 | ] 121 | 122 | [[package]] 123 | name = "jni" 124 | version = "0.21.1" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 127 | dependencies = [ 128 | "cesu8", 129 | "cfg-if", 130 | "combine", 131 | "jni-sys", 132 | "log", 133 | "thiserror", 134 | "walkdir", 135 | "windows-sys 0.45.0", 136 | ] 137 | 138 | [[package]] 139 | name = "jni-sys" 140 | version = "0.3.0" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" 143 | 144 | [[package]] 145 | name = "lazy_static" 146 | version = "1.5.0" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 149 | 150 | [[package]] 151 | name = "libc" 152 | version = "1.0.0-alpha.1" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "7222002e5385b4d9327755661e3847c970e8fbf9dea6da8c57f16e8cfbff53a8" 155 | 156 | [[package]] 157 | name = "log" 158 | version = "0.4.27" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 161 | 162 | [[package]] 163 | name = "memchr" 164 | version = "2.7.5" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 167 | 168 | [[package]] 169 | name = "pin-project-lite" 170 | version = "0.2.16" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 173 | 174 | [[package]] 175 | name = "pin-utils" 176 | version = "0.1.0" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 179 | 180 | [[package]] 181 | name = "proc-macro2" 182 | version = "1.0.95" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 185 | dependencies = [ 186 | "unicode-ident", 187 | ] 188 | 189 | [[package]] 190 | name = "quote" 191 | version = "1.0.40" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 194 | dependencies = [ 195 | "proc-macro2", 196 | ] 197 | 198 | [[package]] 199 | name = "same-file" 200 | version = "1.0.6" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 203 | dependencies = [ 204 | "winapi-util", 205 | ] 206 | 207 | [[package]] 208 | name = "slab" 209 | version = "0.4.10" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" 212 | 213 | [[package]] 214 | name = "syn" 215 | version = "2.0.104" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" 218 | dependencies = [ 219 | "proc-macro2", 220 | "quote", 221 | "unicode-ident", 222 | ] 223 | 224 | [[package]] 225 | name = "thiserror" 226 | version = "1.0.69" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 229 | dependencies = [ 230 | "thiserror-impl", 231 | ] 232 | 233 | [[package]] 234 | name = "thiserror-impl" 235 | version = "1.0.69" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 238 | dependencies = [ 239 | "proc-macro2", 240 | "quote", 241 | "syn", 242 | ] 243 | 244 | [[package]] 245 | name = "unicode-ident" 246 | version = "1.0.18" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 249 | 250 | [[package]] 251 | name = "walkdir" 252 | version = "2.5.0" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 255 | dependencies = [ 256 | "same-file", 257 | "winapi-util", 258 | ] 259 | 260 | [[package]] 261 | name = "winapi-util" 262 | version = "0.1.9" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 265 | dependencies = [ 266 | "windows-sys 0.59.0", 267 | ] 268 | 269 | [[package]] 270 | name = "windows" 271 | version = "0.61.3" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" 274 | dependencies = [ 275 | "windows-collections", 276 | "windows-core", 277 | "windows-future", 278 | "windows-link", 279 | "windows-numerics", 280 | ] 281 | 282 | [[package]] 283 | name = "windows-collections" 284 | version = "0.2.0" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" 287 | dependencies = [ 288 | "windows-core", 289 | ] 290 | 291 | [[package]] 292 | name = "windows-core" 293 | version = "0.61.2" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 296 | dependencies = [ 297 | "windows-implement", 298 | "windows-interface", 299 | "windows-link", 300 | "windows-result", 301 | "windows-strings", 302 | ] 303 | 304 | [[package]] 305 | name = "windows-future" 306 | version = "0.2.1" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" 309 | dependencies = [ 310 | "windows-core", 311 | "windows-link", 312 | "windows-threading", 313 | ] 314 | 315 | [[package]] 316 | name = "windows-implement" 317 | version = "0.60.0" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 320 | dependencies = [ 321 | "proc-macro2", 322 | "quote", 323 | "syn", 324 | ] 325 | 326 | [[package]] 327 | name = "windows-interface" 328 | version = "0.59.1" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 331 | dependencies = [ 332 | "proc-macro2", 333 | "quote", 334 | "syn", 335 | ] 336 | 337 | [[package]] 338 | name = "windows-link" 339 | version = "0.1.3" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 342 | 343 | [[package]] 344 | name = "windows-numerics" 345 | version = "0.2.0" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" 348 | dependencies = [ 349 | "windows-core", 350 | "windows-link", 351 | ] 352 | 353 | [[package]] 354 | name = "windows-result" 355 | version = "0.3.4" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 358 | dependencies = [ 359 | "windows-link", 360 | ] 361 | 362 | [[package]] 363 | name = "windows-strings" 364 | version = "0.4.2" 365 | source = "registry+https://github.com/rust-lang/crates.io-index" 366 | checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 367 | dependencies = [ 368 | "windows-link", 369 | ] 370 | 371 | [[package]] 372 | name = "windows-sys" 373 | version = "0.45.0" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 376 | dependencies = [ 377 | "windows-targets 0.42.2", 378 | ] 379 | 380 | [[package]] 381 | name = "windows-sys" 382 | version = "0.59.0" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 385 | dependencies = [ 386 | "windows-targets 0.52.6", 387 | ] 388 | 389 | [[package]] 390 | name = "windows-targets" 391 | version = "0.42.2" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 394 | dependencies = [ 395 | "windows_aarch64_gnullvm 0.42.2", 396 | "windows_aarch64_msvc 0.42.2", 397 | "windows_i686_gnu 0.42.2", 398 | "windows_i686_msvc 0.42.2", 399 | "windows_x86_64_gnu 0.42.2", 400 | "windows_x86_64_gnullvm 0.42.2", 401 | "windows_x86_64_msvc 0.42.2", 402 | ] 403 | 404 | [[package]] 405 | name = "windows-targets" 406 | version = "0.52.6" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 409 | dependencies = [ 410 | "windows_aarch64_gnullvm 0.52.6", 411 | "windows_aarch64_msvc 0.52.6", 412 | "windows_i686_gnu 0.52.6", 413 | "windows_i686_gnullvm", 414 | "windows_i686_msvc 0.52.6", 415 | "windows_x86_64_gnu 0.52.6", 416 | "windows_x86_64_gnullvm 0.52.6", 417 | "windows_x86_64_msvc 0.52.6", 418 | ] 419 | 420 | [[package]] 421 | name = "windows-threading" 422 | version = "0.1.0" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" 425 | dependencies = [ 426 | "windows-link", 427 | ] 428 | 429 | [[package]] 430 | name = "windows_aarch64_gnullvm" 431 | version = "0.42.2" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 434 | 435 | [[package]] 436 | name = "windows_aarch64_gnullvm" 437 | version = "0.52.6" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 440 | 441 | [[package]] 442 | name = "windows_aarch64_msvc" 443 | version = "0.42.2" 444 | source = "registry+https://github.com/rust-lang/crates.io-index" 445 | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 446 | 447 | [[package]] 448 | name = "windows_aarch64_msvc" 449 | version = "0.52.6" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 452 | 453 | [[package]] 454 | name = "windows_i686_gnu" 455 | version = "0.42.2" 456 | source = "registry+https://github.com/rust-lang/crates.io-index" 457 | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 458 | 459 | [[package]] 460 | name = "windows_i686_gnu" 461 | version = "0.52.6" 462 | source = "registry+https://github.com/rust-lang/crates.io-index" 463 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 464 | 465 | [[package]] 466 | name = "windows_i686_gnullvm" 467 | version = "0.52.6" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 470 | 471 | [[package]] 472 | name = "windows_i686_msvc" 473 | version = "0.42.2" 474 | source = "registry+https://github.com/rust-lang/crates.io-index" 475 | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 476 | 477 | [[package]] 478 | name = "windows_i686_msvc" 479 | version = "0.52.6" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 482 | 483 | [[package]] 484 | name = "windows_x86_64_gnu" 485 | version = "0.42.2" 486 | source = "registry+https://github.com/rust-lang/crates.io-index" 487 | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 488 | 489 | [[package]] 490 | name = "windows_x86_64_gnu" 491 | version = "0.52.6" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 494 | 495 | [[package]] 496 | name = "windows_x86_64_gnullvm" 497 | version = "0.42.2" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 500 | 501 | [[package]] 502 | name = "windows_x86_64_gnullvm" 503 | version = "0.52.6" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 506 | 507 | [[package]] 508 | name = "windows_x86_64_msvc" 509 | version = "0.42.2" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 512 | 513 | [[package]] 514 | name = "windows_x86_64_msvc" 515 | version = "0.52.6" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 518 | 519 | [[package]] 520 | name = "windowsmediacontrol" 521 | version = "1.0.0" 522 | dependencies = [ 523 | "futures", 524 | "jni", 525 | "lazy_static", 526 | "libc", 527 | "windows", 528 | ] 529 | --------------------------------------------------------------------------------