Base project structure and files
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
98
pom.xml
Normal file
98
pom.xml
Normal file
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>space.b00tload</groupId>
|
||||
<artifactId>SpotifyDedupe</artifactId>
|
||||
<version>1.0.0.alpha1</version>
|
||||
|
||||
<name>SpotifyDeduper</name>
|
||||
<description>Removes duplicates from spotify playlists.</description>
|
||||
<inceptionYear>2023</inceptionYear>
|
||||
<url>https://github.com/B00tLoad/SpotifyDedupe</url>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>GNU General Public License v3.0</name>
|
||||
<url>https://github.com/B00tLoad/SpotifyDedupe/blob/master/LICENSE.md</url>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>B00tLoad_</id>
|
||||
<name>Alix von Schirp</name>
|
||||
<email>alix.von-schirp@bootmedia.de</email>
|
||||
<roles>
|
||||
<role>developer</role>
|
||||
</roles>
|
||||
<timezone>Europe/Berlin</timezone>
|
||||
<properties>
|
||||
<disordHandle>Alix | B00tLoad_#9370</disordHandle>
|
||||
<pronouns>she/they</pronouns>
|
||||
</properties>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<issueManagement>
|
||||
<system>GitHub</system>
|
||||
<url>https://github.com/B00tLoad/SpotifyDedupe/issues</url>
|
||||
</issueManagement>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>18</maven.compiler.source>
|
||||
<maven.compiler.target>18</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>single</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<descriptorRefs>
|
||||
<descriptorRef>jar-with-dependencies</descriptorRef>
|
||||
</descriptorRefs>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>space.b00tload.bsu.dedupe.SpotifyDedupe</mainClass>
|
||||
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
|
||||
<addDefaultSpecificationEntries>true</addDefaultSpecificationEntries>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>se.michaelthelin.spotify</groupId>
|
||||
<artifactId>spotify-web-api-java</artifactId>
|
||||
<version>7.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.11.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.electronwill.night-config</groupId>
|
||||
<artifactId>toml</artifactId>
|
||||
<version>3.6.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
32
src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java
Normal file
32
src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java
Normal file
@@ -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<String> 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);
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package space.b00tload.bsu.dedupe.util;
|
||||
|
||||
public class BrowserHelper {
|
||||
|
||||
/**
|
||||
* Opens a <code>url</code> 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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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 <code>javax.crypto.SecretKey</code> 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 <code>java.io.Serializable</code> 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 <code>java.io.Serializable</code> object.
|
||||
* @param file The <code>java.nio.Path</code> where the encrypted file is stored.
|
||||
* @param key The SecretKey (AES) to decrypt the file with
|
||||
* @return The <code>java.io.Serializable</code> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 <code>se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials</code>. 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 <code>AuthorizationCodeCredentials</code> 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 <code>isValid()</code>.
|
||||
* @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 <code>LocalDateTime</code> which represents the latest point in time when the access token is still valid.
|
||||
* @return A <code>LocalDateTime</code> 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 <code>AuthorizationCodeCredentials</code> 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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package space.b00tload.bsu.dedupe.util;
|
||||
|
||||
|
||||
import static space.b00tload.bsu.dedupe.SpotifyDedupe.*;
|
||||
|
||||
public class TokenHelper {
|
||||
|
||||
/**
|
||||
* Manages saving a <code>space.b00tload.bsu.dedupe.util.SpotifyCredentials</code> into "~/.lfm2s/spotify.lfm2scred" using <code>space.b00tload.bsu.dedupe.util.CryptoHelper.serializeEncrypted(...)</code>
|
||||
* @param cred The <code>space.b00tload.bsu.dedupe.util.SpotifyCredentials</code> to be saved
|
||||
*/
|
||||
public static void saveTokens(SpotifyCredentials cred) {
|
||||
CryptoHelper.serializeEncrypted(cred, CREDENTIAL_LOCATION, CryptoHelper.createKeyFromPassword(TomlConfiguration.getString("spotify.caching.password")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages retrieving a <code>space.b00tload.bsu.dedupe.util.SpotifyCredentials</code> from "~/.bsu/dedupe/spotify.bsucred" using <code>space.b00tload.bsu.dedupe.util.CryptoHelper.deserializeEncrypted(...)</code>
|
||||
* @return The retrieved <code>space.b00tload.bsu.dedupe.util.SpotifyCredentials</code>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
16
src/main/resources/config/default.toml
Normal file
16
src/main/resources/config/default.toml
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user