diff --git a/.gitignore b/.gitignore
index a1c2a23..bd657d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,9 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
+
+#JetBrains IntelliJ folder
+/.idea/
+
+#Maven Generated
+/target/
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..0e7e70c
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,98 @@
+
+
+ 4.0.0
+
+ space.b00tload
+ SpotifyDedupe
+ 1.0.0.alpha1
+
+ SpotifyDeduper
+ Removes duplicates from spotify playlists.
+ 2023
+ https://github.com/B00tLoad/SpotifyDedupe
+
+
+
+ GNU General Public License v3.0
+ https://github.com/B00tLoad/SpotifyDedupe/blob/master/LICENSE.md
+
+
+
+
+
+ B00tLoad_
+ Alix von Schirp
+ alix.von-schirp@bootmedia.de
+
+ developer
+
+ Europe/Berlin
+
+ Alix | B00tLoad_#9370
+ she/they
+
+
+
+
+
+ GitHub
+ https://github.com/B00tLoad/SpotifyDedupe/issues
+
+
+
+ 18
+ 18
+ UTF-8
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-assembly-plugin
+ 3.3.0
+
+
+ package
+
+ single
+
+
+
+
+
+ jar-with-dependencies
+
+
+
+ space.b00tload.bsu.dedupe.SpotifyDedupe
+ true
+ true
+
+
+
+
+
+
+
+
+
+ se.michaelthelin.spotify
+ spotify-web-api-java
+ 7.3.0
+
+
+ commons-io
+ commons-io
+ 2.11.0
+
+
+ com.electronwill.night-config
+ toml
+ 3.6.0
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java b/src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java
new file mode 100644
index 0000000..7db5a74
--- /dev/null
+++ b/src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java
@@ -0,0 +1,32 @@
+package space.b00tload.bsu.dedupe;
+
+import space.b00tload.bsu.dedupe.util.TomlConfiguration;
+import space.b00tload.bsu.dedupe.util.VersionChecker;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+
+public class SpotifyDedupe {
+
+ public static final String LINE_SEPERATOR = System.getProperty("line.separator");
+ public static final String USER_HOME = System.getProperty("user.home");
+ public static final String CONFIG_BASE = Paths.get(USER_HOME, ".bsu", "dedupe").toString();
+ public static final Path CREDENTIAL_LOCATION = Paths.get(CONFIG_BASE, "spotify.bsucred");
+
+ private static List requiredConfig = List.of("spotify.client", "spotify.secret");
+
+
+ public static void main(String[] args) {
+ Runtime.getRuntime().addShutdownHook(new Thread(TomlConfiguration::close));
+
+ VersionChecker.checkVerion();
+
+ if(!Paths.get(CONFIG_BASE).toFile().exists()) Paths.get(CONFIG_BASE).toFile().mkdirs();
+
+ TomlConfiguration.validate(requiredConfig);
+
+
+ }
+
+}
diff --git a/src/main/java/space/b00tload/bsu/dedupe/exceptions/ConfigException.java b/src/main/java/space/b00tload/bsu/dedupe/exceptions/ConfigException.java
new file mode 100644
index 0000000..7efa93b
--- /dev/null
+++ b/src/main/java/space/b00tload/bsu/dedupe/exceptions/ConfigException.java
@@ -0,0 +1,21 @@
+package space.b00tload.bsu.dedupe.exceptions;
+
+
+public class ConfigException extends RuntimeException{
+
+ public ConfigException(String message){
+ super(message);
+ }
+
+ public ConfigException(String message, Throwable cause){
+ super(message, cause);
+ }
+
+ public ConfigException(Throwable cause){
+ super(cause);
+ }
+
+ public ConfigException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace){
+ super(message, cause, enableSuppression, writableStackTrace);
+ }
+}
diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/BrowserHelper.java b/src/main/java/space/b00tload/bsu/dedupe/util/BrowserHelper.java
new file mode 100644
index 0000000..d8877e1
--- /dev/null
+++ b/src/main/java/space/b00tload/bsu/dedupe/util/BrowserHelper.java
@@ -0,0 +1,42 @@
+package space.b00tload.bsu.dedupe.util;
+
+public class BrowserHelper {
+
+ /**
+ * Opens a url in the systems default browser
+ * @param url
+ */
+ public static void openInBrowser(String url) {
+
+ String os = System.getProperty("os.name").toLowerCase();
+ ProcessBuilder builder;
+
+ if (os.indexOf("win") >= 0) {
+ // Windows
+ builder = new ProcessBuilder("rundll32.exe","url.dll,FileProtocolHandler", url);
+ } else if (os.indexOf("mac") >= 0) {
+ // Mac
+ builder = new ProcessBuilder("open", url);
+ } else if (os.indexOf("nix") >= 0 || os.indexOf("nux") >= 0) {
+ // Linux
+ os = "linux";
+ builder = new ProcessBuilder("xdg-open", url);
+ } else {
+ System.out.println("Please open the following link:\n"+url);
+ builder = null;
+ }
+
+ try {
+ if (builder != null) {
+ Process exec = builder.start();
+ if (os.equals("linux") && exec.exitValue() == 3) {
+ // on Linux in case of missing browser
+ System.out.println("Please open the following link:\n"+url);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ }
+}
diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/CryptoHelper.java b/src/main/java/space/b00tload/bsu/dedupe/util/CryptoHelper.java
new file mode 100644
index 0000000..91d013e
--- /dev/null
+++ b/src/main/java/space/b00tload/bsu/dedupe/util/CryptoHelper.java
@@ -0,0 +1,80 @@
+package space.b00tload.bsu.dedupe.util;
+
+
+import javax.crypto.*;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.*;
+import java.nio.file.Path;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+
+public class CryptoHelper {
+
+ /**
+ * Creates a javax.crypto.SecretKey from a provided password
+ * @param pass The password
+ * @return the generated secret key
+ */
+ public static SecretKey createKeyFromPassword(String pass){
+ try {
+ KeySpec spec = new PBEKeySpec(pass.toCharArray(), "abcdefghijklmnop".getBytes(), 65536, 256); // AES-256
+ SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
+ byte[] key = f.generateSecret(spec).getEncoded();
+ return new SecretKeySpec(key, "AES");
+ } catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Saves a java.io.Serializable Object into an encrypted file
+ * @param obj The object to save
+ * @param file The Path where to save the file
+ * @param key The SecretKey (AES) to encrypt the file with
+ */
+ public static void serializeEncrypted(Serializable obj, Path file, SecretKey key){
+ try {
+ if(file.toFile().exists()) file.toFile().delete();
+ if(!file.getParent().toFile().exists()) file.getParent().toFile().mkdirs();
+ file.toFile().createNewFile();
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ IvParameterSpec iv = new IvParameterSpec("abcdefghijklmnop".getBytes());
+ cipher.init(Cipher.ENCRYPT_MODE, key, iv);
+ SealedObject sealedObject = new SealedObject( obj, cipher);
+ CipherOutputStream cipherOutputStream = new CipherOutputStream( new BufferedOutputStream( new FileOutputStream( file.toFile() ) ), cipher );
+ ObjectOutputStream outputStream = new ObjectOutputStream( cipherOutputStream );
+ outputStream.writeObject( sealedObject );
+ outputStream.close();
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IOException | IllegalBlockSizeException | InvalidAlgorithmParameterException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Reads an encrypted file into a java.io.Serializable object.
+ * @param file The java.nio.Path where the encrypted file is stored.
+ * @param key The SecretKey (AES) to decrypt the file with
+ * @return The java.io.Serializable object read from the file.
+ */
+ public static Serializable deserializeEncrypted(Path file, SecretKey key) {
+ Serializable ret;
+ try {
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ IvParameterSpec iv = new IvParameterSpec("abcdefghijklmnop".getBytes());
+ cipher.init(Cipher.DECRYPT_MODE, key, iv);
+ CipherInputStream cipherInputStream = new CipherInputStream( new BufferedInputStream( new FileInputStream( file.toFile() ) ), cipher );
+ ObjectInputStream inputStream = new ObjectInputStream( cipherInputStream );
+ SealedObject sealedObject = (SealedObject) inputStream.readObject();
+ ret = (Serializable) sealedObject.getObject(cipher);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IOException | IllegalBlockSizeException | ClassNotFoundException | BadPaddingException | InvalidAlgorithmParameterException e) {
+ throw new RuntimeException(e);
+ }
+ return ret;
+ }
+
+}
diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/SpotifyCredentials.java b/src/main/java/space/b00tload/bsu/dedupe/util/SpotifyCredentials.java
new file mode 100644
index 0000000..a4b37ae
--- /dev/null
+++ b/src/main/java/space/b00tload/bsu/dedupe/util/SpotifyCredentials.java
@@ -0,0 +1,70 @@
+package space.b00tload.bsu.dedupe.util;
+import se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials;
+
+import java.io.Serializable;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+/**
+ * A wrapper class for se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials. Implements checking validity of access token.
+ */
+public class SpotifyCredentials implements Serializable {
+
+ private String accessToken;
+ private String refreshToken;
+ private LocalDateTime validUntil;
+
+ /**
+ * Initializes the class
+ * @param cred The AuthorizationCodeCredentials to be saved. Recommended for use with recently (last few seconds) generated Credentials
+ */
+ public SpotifyCredentials(AuthorizationCodeCredentials cred){
+ this.accessToken = cred.getAccessToken();
+ this.refreshToken = cred.getRefreshToken();
+ this.validUntil = LocalDateTime.now(Clock.systemDefaultZone()).plusSeconds(cred.getExpiresIn());
+ }
+
+ /**
+ * Get the access token. It becomes invalid after a certain period of time. Check validity with isValid().
+ * @return An access token that can be provided in subsequent calls, for example to Spotify Web API services.
+ */
+ public String getAccessToken(){
+ return accessToken;
+ }
+
+ /**
+ * Get the refresh token. This token can be sent to the Spotify Accounts service in place of an authorization code to retrieve a new access token.
+ * @return A token that can be sent to the Spotify Accounts service in place of an access token.
+ */
+ public String getRefreshToken(){
+ return refreshToken;
+ }
+
+ /**
+ * Returns a LocalDateTime which represents the latest point in time when the access token is still valid.
+ * @return A LocalDateTime representing the latest point in time of access token validity
+ */
+ public LocalDateTime getValidUntil(){
+ return validUntil;
+ }
+
+ /**
+ * Checks whether the saved access token is still valid for use in calls, for example to the Spotify Web API services.
+ * @return true if the access token is still valid for use, false if not.
+ */
+ public boolean isValid(){
+ return LocalDateTime.now(Clock.systemDefaultZone()).isBefore(getValidUntil());
+ }
+
+ /**
+ * Refreshes the access token. If a new refresh token is provided it will be saved as well.
+ * @param cred The AuthorizationCodeCredentials to be saved. Recommended for use with recently (last few seconds) generated Credentials
+ */
+ public void refreshCredentials(AuthorizationCodeCredentials cred){
+ this.accessToken = cred.getAccessToken();
+ if(Objects.nonNull(cred.getRefreshToken())) this.refreshToken = cred.getRefreshToken();
+ this.validUntil = LocalDateTime.now(Clock.systemDefaultZone()).plusSeconds(cred.getExpiresIn());
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/TokenHelper.java b/src/main/java/space/b00tload/bsu/dedupe/util/TokenHelper.java
new file mode 100644
index 0000000..56a9e79
--- /dev/null
+++ b/src/main/java/space/b00tload/bsu/dedupe/util/TokenHelper.java
@@ -0,0 +1,31 @@
+package space.b00tload.bsu.dedupe.util;
+
+
+import static space.b00tload.bsu.dedupe.SpotifyDedupe.*;
+
+public class TokenHelper {
+
+ /**
+ * Manages saving a space.b00tload.bsu.dedupe.util.SpotifyCredentials into "~/.lfm2s/spotify.lfm2scred" using space.b00tload.bsu.dedupe.util.CryptoHelper.serializeEncrypted(...)
+ * @param cred The space.b00tload.bsu.dedupe.util.SpotifyCredentials to be saved
+ */
+ public static void saveTokens(SpotifyCredentials cred) {
+ CryptoHelper.serializeEncrypted(cred, CREDENTIAL_LOCATION, CryptoHelper.createKeyFromPassword(TomlConfiguration.getString("spotify.caching.password")));
+ }
+
+ /**
+ * Manages retrieving a space.b00tload.bsu.dedupe.util.SpotifyCredentials from "~/.bsu/dedupe/spotify.bsucred" using space.b00tload.bsu.dedupe.util.CryptoHelper.deserializeEncrypted(...)
+ * @return The retrieved space.b00tload.bsu.dedupe.util.SpotifyCredentials
+ */
+ public static SpotifyCredentials fetchTokens() {
+ return (SpotifyCredentials) CryptoHelper.deserializeEncrypted(CREDENTIAL_LOCATION, CryptoHelper.createKeyFromPassword(TomlConfiguration.getString("spotify.caching.password")));
+ }
+
+ /**
+ * Checks whether the saved SpotifyCredentials at "~/.bsu/dedupe/spotify.bsucred" exist
+ * @return true if file exists, false if not
+ */
+ public static boolean existsTokens(){
+ return CREDENTIAL_LOCATION.toFile().exists();
+ }
+}
diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/TomlConfiguration.java b/src/main/java/space/b00tload/bsu/dedupe/util/TomlConfiguration.java
new file mode 100644
index 0000000..fd3849c
--- /dev/null
+++ b/src/main/java/space/b00tload/bsu/dedupe/util/TomlConfiguration.java
@@ -0,0 +1,82 @@
+package space.b00tload.bsu.dedupe.util;
+
+import com.electronwill.nightconfig.core.file.FileConfig;
+import space.b00tload.bsu.dedupe.exceptions.ConfigException;
+
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.util.List;
+
+import static space.b00tload.bsu.dedupe.SpotifyDedupe.CONFIG_BASE;
+
+public class TomlConfiguration {
+
+ static {
+ try {
+ init();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static FileConfig config;
+
+ private static void init() throws IOException {
+ config = FileConfig.builder(Paths.get(CONFIG_BASE, "config.toml")).defaultResource("/config/default.toml").autosave().autoreload().build();
+ config.load();
+ checkVersion();
+ }
+
+ public static void validate(List required){
+ if(isDefault()) throw new ConfigException("You have not modified your config at " + config.getNioPath().toString() + " yet.");
+ for(String s : required){
+ if(!config.contains(s)){
+ throw new ConfigException("Missing entry "+ s);
+ }
+ }
+ }
+
+ public static void close() {
+ config.save();
+ config.close();
+ }
+
+ public static void checkVersion(){
+ String currentVersion = TomlConfiguration.class.getPackage().getImplementationVersion();
+ String configVersion = getString("file.version");
+ if (currentVersion == null) {
+ System.out.println("Error: Failed to retrieve current version. Assuming 0.0.1");
+ currentVersion = "0.0.1";
+ }
+ if (configVersion == null) {
+ throw new ConfigException("Invalid config. \"file.version\" is not set.");
+ } else if (currentVersion.compareTo(configVersion) < 0) {
+ System.out.println("Software updated. Please check wiki for migration help.");
+ setString("file.version", currentVersion);
+ }
+ }
+
+ public static boolean isDefault(){
+ return config.getOrElse("default", false);
+ }
+
+ public static String getString(String key){
+ return config.get(key);
+ }
+
+ public static void setString(String key, String value){
+ config.set(key, value);
+ }
+
+ public static boolean getBoolean(String key){
+ return config.get(key);
+ }
+
+ public static void setBoolean(String key, boolean value){
+ config.set(key, value);
+ }
+
+
+
+
+}
diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/VersionChecker.java b/src/main/java/space/b00tload/bsu/dedupe/util/VersionChecker.java
new file mode 100644
index 0000000..1c14fd0
--- /dev/null
+++ b/src/main/java/space/b00tload/bsu/dedupe/util/VersionChecker.java
@@ -0,0 +1,45 @@
+package space.b00tload.bsu.dedupe.util;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.commons.io.IOUtils;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+public class VersionChecker {
+
+ private static final String GITHUB_API_BASE_URL = "https://api.github.com/repos/B00tLoad/SpotifyDedupe";
+
+
+ public static void checkVerion() {
+ String currentVersion = VersionChecker.class.getPackage().getImplementationVersion();
+ String latestVersion = fetchLatestReleaseVersion();
+ if (currentVersion == null) {
+ System.out.println("Error: Failed to retrieve current version");
+ } else if (latestVersion == null) {
+ System.out.println("Error: Failed to retrieve latest release version");
+ } else if (currentVersion.compareTo(latestVersion) < 0) {
+ System.out.println("A new version is available: " + latestVersion);
+ } else {
+ System.out.println("You are running the latest version: " + currentVersion);
+ }
+ }
+
+ private static String fetchLatestReleaseVersion() {
+ try (InputStream inputStream = (new URL(GITHUB_API_BASE_URL + "/releases/latest")).openStream()) {
+ String response = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
+ JsonObject release = JsonParser.parseString(response).getAsJsonObject();
+ return release.get("tag_name").getAsString().substring(1);
+ } catch (FileNotFoundException ex){
+ return null;
+ } catch (NullPointerException | IOException ex){
+ ex.printStackTrace();
+ return null;
+ }
+ }
+
+}
diff --git a/src/main/resources/config/default.toml b/src/main/resources/config/default.toml
new file mode 100644
index 0000000..1beccb4
--- /dev/null
+++ b/src/main/resources/config/default.toml
@@ -0,0 +1,16 @@
+# Remove after setting your values
+default = true
+
+# Set your client ID and secret from your spotify application
+[spotify]
+client = "your_client_id"
+secret = "your_client_secret"
+
+# To enable caching auth tokens, set enabled to true and a password
+[spotify.caching]
+enabled = false
+password = "set password here"
+
+# Ignore, autoset by software
+[file]
+version = "1.0.0"
\ No newline at end of file