Base project structure and files

This commit is contained in:
2023-03-12 17:53:52 +01:00
parent 213b889acb
commit 9b00ab0bae
11 changed files with 523 additions and 0 deletions

6
.gitignore vendored
View File

@@ -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
View 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>

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View 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"