Initialer commit

This commit is contained in:
2026-05-03 21:51:45 +02:00
commit 7dd108a58e
117 changed files with 9145 additions and 0 deletions

18
.classpath Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
build/
.gradle/

28
.project Normal file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>libredeck-web</name>
<comment>Project libredeck-web created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.springframework.ide.eclipse.boot.validation.springbootbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
</projectDescription>

View File

@@ -0,0 +1,13 @@
arguments=
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=
jvm.arguments=
offline.mode=false
override.workspace.settings=false
show.console.view=false
show.executions.view=false

View File

@@ -0,0 +1,2 @@
boot.validation.initialized=true
eclipse.preferences.version=1

4
Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

34
bin/main/application.yml Normal file
View File

@@ -0,0 +1,34 @@
server:
port: 8091
spring:
thymeleaf:
cache: false
# ── Spotify (Client Credentials no user login required for public playlists) ──
# Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET as environment variables.
# Create credentials at https://developer.spotify.com/dashboard
spotify:
client-id: ${SPOTIFY_CLIENT_ID:}
client-secret: ${SPOTIFY_CLIENT_SECRET:}
redirect-uri: ${SPOTIFY_REDIRECT_URI:http://localhost:8091/spotify/callback}
# ── Tidal OAuth2 PKCE ──
# Set TIDAL_CLIENT_ID as an environment variable.
# Register an app at https://developer.tidal.com
tidal:
client-id: ${TIDAL_CLIENT_ID:}
client-secret: ${TIDAL_CLIENT_SECRET:}
country-code: ${TIDAL_COUNTRY_CODE:DE}
# ── Google / YouTube Data API ──
# Set GOOGLE_API_KEY as an environment variable.
# Create an API key at https://console.cloud.google.com and enable YouTube Data API v3.
google:
api-key: ${GOOGLE_API_KEY:}
# ── Deezer OAuth commented out until app registration is available ──
# deezer:
# app-id: ${DEEZER_APP_ID}
# app-secret: ${DEEZER_APP_SECRET}
# redirect-uri: ${DEEZER_REDIRECT_URI:http://localhost:8091/auth/callback}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playlists libredeck</title>
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<div class="container">
<header>
<h1 class="logo"><a th:href="@{/}">libre<span>deck</span></a></h1>
<div class="header-actions">
<span th:text="${provider}" class="provider-badge"></span>
<a th:href="@{/auth/logout}" class="btn btn-sm">Abmelden</a>
</div>
</header>
<main>
<h2>Deine Playlists</h2>
<p class="hint">Wähle eine Playlist aus, um ein druckfertiges PDF zu generieren.</p>
<div class="playlist-grid">
<div th:each="pl : ${playlists}" class="playlist-card">
<img th:if="${pl.coverUrl != null and !pl.coverUrl.isEmpty()}"
th:src="${pl.coverUrl}" alt="Cover" class="playlist-cover">
<div th:unless="${pl.coverUrl != null and !pl.coverUrl.isEmpty()}"
class="playlist-cover placeholder"></div>
<div class="playlist-info">
<h3 th:text="${pl.title}">Playlist Name</h3>
<p th:text="${pl.trackCount} + ' Titel'" class="track-count"></p>
</div>
<a th:href="@{/generate(playlistId=${pl.id}, playlistTitle=${pl.title})}"
class="btn btn-generate"
th:title="'PDF für ' + ${pl.title} + ' generieren'">
PDF generieren
</a>
</div>
</div>
<div th:if="${#lists.isEmpty(playlists)}" class="empty-state">
<p>Keine Playlists gefunden.</p>
</div>
</main>
</div>
<div id="loading-overlay" style="display:none">
<div class="spinner"></div>
<p>PDF wird generiert…</p>
</div>
<script>
document.querySelectorAll('.btn-generate').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('loading-overlay').style.display = 'flex';
});
});
</script>
</body>
</html>

56
build.gradle Normal file
View File

@@ -0,0 +1,56 @@
plugins {
id 'java'
id 'eclipse'
id 'org.springframework.boot' version '3.5.14'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'de.libredeck'
version = '0.1.0-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.apache.pdfbox:pdfbox:3.0.2'
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.google.zxing:javase:3.5.3'
implementation 'com.fasterxml.jackson.core:jackson-databind'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
eclipse {
jdt {
file {
withProperties { props ->
props['encoding/<project>'] = 'UTF-8'
}
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.compilerArgs << '-parameters'
}
tasks.named('test') {
useJUnitPlatform()
}

18
deploy.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
REMOTE_CONTEXT="proxmox-remote"
IMAGE_NAME="libredeck-web"
TAG="latest"
echo "--- 1. Gradle Build: Erstelle JAR und Docker Image lokal ---"
./gradlew bootJar
docker build -t $IMAGE_NAME:$TAG .
echo "--- 2. Transfer: Image zum Proxmox-Server schieben ---"
docker save $IMAGE_NAME:$TAG | docker --context $REMOTE_CONTEXT load
echo "--- 3. Remote Deployment: Starten auf Proxmox ---"
docker --context $REMOTE_CONTEXT compose up -d --force-recreate
echo "--- Fertig! Backend läuft auf Port 8091 ---"

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
libredeck-web:
image: libredeck-web:latest
container_name: libredeck-web
restart: unless-stopped
ports:
- "8091:8091"
environment:
- SPRING_PROFILES_ACTIVE=prod
- SERVER_PORT=8091
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}
- DEEZER_APP_ID=${DEEZER_APP_ID}
- DEEZER_APP_SECRET=${DEEZER_APP_SECRET}
- DEEZER_REDIRECT_URI=${DEEZER_REDIRECT_URI}
- TIDAL_CLIENT_ID=${TIDAL_CLIENT_ID}
- TIDAL_CLIENT_SECRET=${TIDAL_CLIENT_SECRET}
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8091/actuator/health"]
interval: 30s
timeout: 5s
retries: 3

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
gradlew vendored Executable file
View File

@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,10 @@
---
name: Secrets in .env files
description: User stores credentials in .env files, never in code or git
type: feedback
---
User stores sensitive credentials (API keys, secrets) in `.env` files next to `docker-compose.yml`. Always create/suggest `.env` + `.gitignore` together.
**Why:** User explicitly set up Spotify credentials this way.
**How to apply:** When any new secret/credential is added, offer to write it to `.env` and ensure `.gitignore` covers it.

29
nextsteps.txt Normal file
View File

@@ -0,0 +1,29 @@
1. Deezer App anlegen: https://developers.deezer.com/myapps → Redirect URI http://localhost:8080/auth/callback
2. Backend starten:
cd libredeck-web
DEEZER_APP_ID=xxx DEEZER_APP_SECRET=yyy mvn spring-boot:run
3. Android: In app/build.gradle die BACKEND_URL anpassen, dann in Android Studio öffnen und builden
4. Gradle Wrapper fehlt noch im Android-Projekt — entweder gradle wrapper ausführen oder aus linkster-android kopieren
Was gebaut:
7 neue Backend-Dateien:
- AppleMusicConfig Properties: apple.music.team-id, apple.music.key-id, apple.music.private-key
- AppleMusicTokenGenerator erzeugt und cached den Developer-JWT (ES256, standard Java-Crypto, keine neuen Deps)
- AppleMusicTokenStore Session-scoped Storage für den Music User Token (wie bei Spotify/Tidal)
- AppleMusicApiClient HTTP-Client gegen api.music.apple.com; unterstützt Catalog- und Library-Playlists, ThreadLocal-Token-Pinning für async Jobs
- AppleMusicProvider implementiert StreamingProvider, wird automatisch im Registry registriert
- AppleMusicAuthRequiredException
- AppleMusicController Endpunkte: /apple/configured, /apple/connected, /apple/developer-token, /apple/token, /apple/disconnect, /apple/playlists
Geändert:
- GenerateController Apple Music Auth-Check (nur für Library-Playlists) + Token-Pinning
- index.html 4. Service "Apple Music" im Switcher, MusicKit JS v3 Auth-Flow, Playlist-Picker
Konfiguration (in .env eintragen):
APPLE_MUSIC_TEAM_ID=XXXXXXXXXX
APPLE_MUSIC_KEY_ID=XXXXXXXXXX
APPLE_MUSIC_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----
Benötigt wird ein MusicKit-Key aus dem Apple Developer Portal. Ist nichts konfiguriert, bleibt das Panel sichtbar aber zeigt "nicht konfiguriert".

8
settings.gradle Normal file
View File

@@ -0,0 +1,8 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'libredeck-web'

View File

@@ -0,0 +1,11 @@
package de.oaa.libredeck.web;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LibredeckWebApplication {
public static void main(String[] args) {
SpringApplication.run(LibredeckWebApplication.class, args);
}
}

View File

@@ -0,0 +1,16 @@
package de.oaa.libredeck.web.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Getter
@Setter
@Component
@ConfigurationProperties("apple.music")
public class AppleMusicConfig {
private String teamId = "";
private String keyId = "";
private String privateKey = ""; // PEM content of the .p8 file from Apple Developer Portal
}

View File

@@ -0,0 +1,19 @@
package de.oaa.libredeck.web.config;
// -------------------------------------------------------------------------
// DeezerConfig commented out until Deezer re-enables app registration.
// Re-enable together with AuthController and the OAuth methods in DeezerProvider.
// -------------------------------------------------------------------------
// import lombok.Data;
// import org.springframework.boot.context.properties.ConfigurationProperties;
// import org.springframework.stereotype.Component;
//
// @Component
// @ConfigurationProperties(prefix = "deezer")
// @Data
// public class DeezerConfig {
// private String appId;
// private String appSecret;
// private String redirectUri;
// }

View File

@@ -0,0 +1,16 @@
package de.oaa.libredeck.web.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Getter
@Setter
@Component
@ConfigurationProperties("spotify")
public class SpotifyConfig {
private String clientId = "";
private String clientSecret = "";
private String redirectUri = "http://localhost:8091/spotify/callback";
}

View File

@@ -0,0 +1,16 @@
package de.oaa.libredeck.web.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Getter
@Setter
@Component
@ConfigurationProperties("tidal")
public class TidalConfig {
private String clientId = "";
private String clientSecret = "";
private String countryCode = "DE";
}

View File

@@ -0,0 +1,14 @@
package de.oaa.libredeck.web.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Getter
@Setter
@Component
@ConfigurationProperties("google")
public class YouTubeConfig {
private String apiKey = "";
}

View File

@@ -0,0 +1,85 @@
package de.oaa.libredeck.web.controller;
import java.io.IOException;
import java.util.List;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.libredeck.web.model.Track;
import de.oaa.libredeck.web.service.pdf.PdfCardGenerator;
import de.oaa.libredeck.web.service.streaming.StreamingProvider;
import de.oaa.libredeck.web.service.streaming.StreamingProviderRegistry;
import lombok.RequiredArgsConstructor;
/**
* Stateless REST API for the Android app.
* No authentication required uses the Deezer public API.
*/
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class ApiController {
private final StreamingProviderRegistry registry;
private final PdfCardGenerator pdfGenerator;
@GetMapping("/generate")
public ResponseEntity<byte[]> generate(@RequestParam(name = "playlistUrl") String playlistUrl) throws IOException {
StreamingProvider provider = registry.findForUrl(playlistUrl)
.orElseThrow(() -> new IllegalArgumentException("Unsupported URL: " + playlistUrl));
String playlistId = provider.extractPlaylistId(playlistUrl);
String playlistTitle = provider.fetchPlaylistTitle(playlistId);
List<Track> tracks = provider.fetchTracks(playlistId);
byte[] pdf = pdfGenerator.generate(playlistTitle, tracks);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + sanitize(playlistTitle) + ".pdf\"")
.body(pdf);
}
private String sanitize(String name) {
return name.replaceAll("[^a-zA-Z0-9_\\-]", "_").toLowerCase();
}
// -------------------------------------------------------------------------
// OAuth endpoints commented out until Deezer re-enables app registration.
// -------------------------------------------------------------------------
// @GetMapping("/auth/{provider}/exchange")
// public OAuthToken exchangeCode(@PathVariable String provider, @RequestParam String code) throws IOException {
// return registry.get(provider).exchangeCode(code);
// }
//
// @GetMapping("/{provider}/playlists")
// public List<PlaylistSummary> playlists(@PathVariable String provider,
// @RequestHeader(HttpHeaders.AUTHORIZATION) String auth) throws IOException {
// return registry.get(provider).fetchPlaylists(extractToken(auth));
// }
//
// @GetMapping("/{provider}/generate")
// public ResponseEntity<byte[]> generateWithAuth(@PathVariable String provider,
// @RequestParam String playlistId,
// @RequestParam String playlistTitle,
// @RequestHeader(HttpHeaders.AUTHORIZATION) String auth) throws IOException {
// String token = extractToken(auth);
// List<Track> tracks = registry.get(provider).fetchTracks(token, playlistId);
// byte[] pdf = pdfGenerator.generate(playlistTitle, tracks);
// return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
// .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + sanitize(playlistTitle) + ".pdf\"")
// .body(pdf);
// }
//
// private String extractToken(String authHeader) {
// if (authHeader == null || !authHeader.startsWith("Bearer "))
// throw new IllegalArgumentException("Missing or invalid Authorization header");
// return authHeader.substring(7);
// }
}

View File

@@ -0,0 +1,77 @@
package de.oaa.libredeck.web.controller;
import de.oaa.libredeck.web.model.PlaylistSummary;
import de.oaa.libredeck.web.service.applemusic.AppleMusicApiClient;
import de.oaa.libredeck.web.service.applemusic.AppleMusicAuthRequiredException;
import de.oaa.libredeck.web.service.applemusic.AppleMusicTokenGenerator;
import de.oaa.libredeck.web.service.applemusic.AppleMusicTokenStore;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/apple")
@RequiredArgsConstructor
public class AppleMusicController {
private final AppleMusicTokenStore tokenStore;
private final AppleMusicApiClient apiClient;
private final AppleMusicTokenGenerator tokenGen;
@GetMapping("/configured")
public Map<String, Boolean> configured() {
return Map.of("configured", tokenGen.isConfigured());
}
@GetMapping("/connected")
public Map<String, Boolean> connected() {
return Map.of("connected", tokenStore.hasToken());
}
/** Returns the developer JWT for MusicKit JS initialisation (public JWT is designed to be shared). */
@GetMapping("/developer-token")
public ResponseEntity<?> developerToken() {
if (!tokenGen.isConfigured()) {
return ResponseEntity.status(503).body(Map.of("error", "not_configured"));
}
try {
return ResponseEntity.ok(Map.of("token", tokenGen.getDeveloperToken()));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
}
/** Receives the Music User Token obtained by MusicKit JS in the browser and stores it in the session. */
@PostMapping("/token")
public Map<String, String> setToken(@RequestBody Map<String, String> body) {
String token = body.getOrDefault("musicUserToken", "");
if (!token.isBlank()) tokenStore.setMusicUserToken(token);
return Map.of("status", "ok");
}
@PostMapping("/disconnect")
public Map<String, String> disconnect() {
tokenStore.setMusicUserToken(null);
return Map.of("status", "ok");
}
@GetMapping("/playlists")
public ResponseEntity<?> playlists() {
if (!tokenStore.hasToken()) {
return ResponseEntity.status(401).body(Map.of("error", "not_authenticated"));
}
try {
List<PlaylistSummary> list = apiClient.fetchUserPlaylists();
return ResponseEntity.ok(list);
} catch (AppleMusicAuthRequiredException e) {
tokenStore.setMusicUserToken(null);
return ResponseEntity.status(401).body(Map.of("error", "not_authenticated"));
} catch (IOException e) {
return ResponseEntity.status(502).body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,62 @@
package de.oaa.libredeck.web.controller;
// -------------------------------------------------------------------------
// AuthController commented out until Deezer re-enables app registration.
// Re-enable this class and DeezerConfig + the OAuth methods in DeezerProvider
// once a Deezer app-id / app-secret is available.
// -------------------------------------------------------------------------
// import de.libredeck.web.model.OAuthToken;
// import de.libredeck.web.service.streaming.StreamingProvider;
// import de.libredeck.web.service.streaming.StreamingProviderRegistry;
// import jakarta.servlet.http.HttpServletResponse;
// import jakarta.servlet.http.HttpSession;
// import lombok.RequiredArgsConstructor;
// import org.springframework.stereotype.Controller;
// import org.springframework.web.bind.annotation.*;
// import java.io.IOException;
// import java.util.UUID;
//
// @Controller
// @RequestMapping("/auth")
// @RequiredArgsConstructor
// public class AuthController {
//
// static final String SESSION_TOKEN = "access_token";
// static final String SESSION_PROVIDER = "provider_id";
// static final String SESSION_STATE = "oauth_state";
//
// private final StreamingProviderRegistry registry;
//
// @GetMapping("/{provider}/login")
// public void login(@PathVariable String provider,
// @RequestParam(defaultValue = "web") String platform,
// HttpSession session, HttpServletResponse response) throws IOException {
// StreamingProvider p = registry.get(provider);
// String state = platform + ":" + UUID.randomUUID();
// session.setAttribute(SESSION_STATE, state);
// session.setAttribute(SESSION_PROVIDER, provider);
// response.sendRedirect(p.buildAuthorizationUrl(state));
// }
//
// @GetMapping("/callback")
// public String callback(@RequestParam String code,
// @RequestParam(required = false) String state,
// HttpSession session) throws IOException {
// String savedState = (String) session.getAttribute(SESSION_STATE);
// if (savedState == null || !savedState.equals(state)) return "redirect:/?error=state_mismatch";
// String providerId = (String) session.getAttribute(SESSION_PROVIDER);
// OAuthToken token = registry.get(providerId).exchangeCode(code);
// session.setAttribute(SESSION_TOKEN, token.accessToken());
// session.removeAttribute(SESSION_STATE);
// if (state.startsWith("android:"))
// return "redirect:libredeck://auth/callback?access_token=" + token.accessToken();
// return "redirect:/playlists";
// }
//
// @GetMapping("/logout")
// public String logout(HttpSession session) {
// session.invalidate();
// return "redirect:/";
// }
// }

View File

@@ -0,0 +1,277 @@
package de.oaa.libredeck.web.controller;
import de.oaa.libredeck.web.model.Track;
import de.oaa.libredeck.web.service.GenerationJob;
import de.oaa.libredeck.web.service.JobStore;
import de.oaa.libredeck.web.service.MusicBrainzClient;
import de.oaa.libredeck.web.service.applemusic.AppleMusicApiClient;
import de.oaa.libredeck.web.service.applemusic.AppleMusicTokenStore;
import de.oaa.libredeck.web.service.pdf.PdfCardGenerator;
import de.oaa.libredeck.web.service.pdf.QrCodeGenerator;
import de.oaa.libredeck.web.service.spotify.SpotifyApiClient;
import de.oaa.libredeck.web.service.spotify.SpotifyTokenStore;
import de.oaa.libredeck.web.service.streaming.StreamingProvider;
import de.oaa.libredeck.web.service.streaming.StreamingProviderRegistry;
import de.oaa.libredeck.web.service.tidal.TidalApiClient;
import de.oaa.libredeck.web.service.tidal.TidalTokenStore;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.Optional;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Slf4j
@RestController
@RequestMapping("/generate")
@RequiredArgsConstructor
public class GenerateController {
private final StreamingProviderRegistry registry;
private final PdfCardGenerator pdfGenerator;
private final QrCodeGenerator qrGenerator;
private final JobStore jobStore;
private final MusicBrainzClient musicBrainz;
private final SpotifyTokenStore spotifyTokenStore;
private final SpotifyApiClient spotifyApiClient;
private final TidalTokenStore tidalTokenStore;
private final TidalApiClient tidalApiClient;
private final AppleMusicTokenStore appleMusicTokenStore;
private final AppleMusicApiClient appleMusicApiClient;
/**
* Starts an async job: fetches tracks, resolves release years via the streaming
* provider (primary) and MusicBrainz (fallback), then enters REVIEW_READY so
* the user can verify and adjust all years before PDF generation.
*/
@GetMapping("/lookup")
public Map<String, Object> lookup(@RequestParam(name = "playlistUrl") String playlistUrl) {
// Check auth before spawning the async job
Optional<StreamingProvider> providerOpt = registry.findForUrl(playlistUrl);
if (providerOpt.isPresent() && "spotify".equals(providerOpt.get().getId())
&& !spotifyTokenStore.hasValidToken()) {
String authUrl = "/spotify/auth?returnUrl=" + URLEncoder.encode(playlistUrl, StandardCharsets.UTF_8);
return Map.of("spotifyAuthRequired", true, "authUrl", authUrl);
}
if (providerOpt.isPresent() && "tidal".equals(providerOpt.get().getId())
&& !tidalTokenStore.hasValidToken()) {
String authUrl = "/tidal/auth?returnUrl=" + URLEncoder.encode(playlistUrl, StandardCharsets.UTF_8);
return Map.of("tidalAuthRequired", true, "authUrl", authUrl);
}
// Library playlists require a Music User Token; catalog URLs work without auth
if (providerOpt.isPresent() && "applemusic".equals(providerOpt.get().getId())
&& playlistUrl.contains("/library/")
&& !appleMusicTokenStore.hasToken()) {
return Map.of("appleMusicAuthRequired", true);
}
GenerationJob job = jobStore.create();
// Capture tokens on the request thread — session context won't be available on the async thread
boolean isSpotify = providerOpt.isPresent() && "spotify".equals(providerOpt.get().getId());
boolean isTidal = providerOpt.isPresent() && "tidal".equals(providerOpt.get().getId());
boolean isAppleMusic = providerOpt.isPresent() && "applemusic".equals(providerOpt.get().getId());
String pinnedToken = isSpotify ? spotifyTokenStore.getAccessToken() : null;
String tidalToken = isTidal ? tidalTokenStore.getAccessToken() : null;
String tidalCountry = isTidal ? tidalTokenStore.getCountryCode() : null;
String appleMusicMut = isAppleMusic ? appleMusicTokenStore.getMusicUserToken() : null;
CompletableFuture.runAsync(() -> {
if (pinnedToken != null) spotifyApiClient.pinTokenForThread(pinnedToken);
if (tidalToken != null) tidalApiClient.pinTokenForThread(tidalToken, tidalCountry);
if (appleMusicMut != null) appleMusicApiClient.pinMusicUserTokenForThread(appleMusicMut);
try {
job.phase = GenerationJob.Phase.FETCHING;
StreamingProvider provider = registry.findForUrl(playlistUrl)
.orElseThrow(() -> new IllegalArgumentException(
"Diese URL wird nicht unterstützt. Bitte einen Playlist-Link von Spotify, Tidal, Deezer, Apple Music oder YouTube eingeben."));
String playlistId = provider.extractPlaylistId(playlistUrl);
job.playlistTitle = provider.fetchPlaylistTitle(playlistId);
List<Track> raw = provider.fetchTracks(playlistId);
if (raw.isEmpty()) throw new IllegalStateException("Die Playlist enthält keine Titel.");
job.phase = GenerationJob.Phase.LOOKUP;
job.total = raw.size();
List<Track> enriched = new ArrayList<>(raw.size());
int done = 0;
for (Track t : raw) {
if (job.phase == GenerationJob.Phase.CANCELLED) return;
// 1. year from fetchTracks (Spotify embeds it), or provider lookup (Deezer)
String year = t.releaseYear().isEmpty() ? provider.fetchTrackYear(t.id()) : t.releaseYear();
// 2. MusicBrainz fallback
if (year.isEmpty()) {
year = musicBrainz.fetchYear(t.artist(), t.title());
}
enriched.add(new Track(t.id(), t.title(), t.artist(), year, t.streamingUrl()));
job.done = ++done;
}
job.tracks = enriched;
job.phase = GenerationJob.Phase.REVIEW_READY;
} catch (Exception e) {
log.warn("Lookup failed for job {}: {}", job.id, e.getMessage());
job.errorMessage = e.getMessage();
job.phase = GenerationJob.Phase.ERROR;
} finally {
spotifyApiClient.clearThreadToken();
tidalApiClient.clearThreadToken();
appleMusicApiClient.clearThreadToken();
}
});
return Map.of("jobId", job.id);
}
/**
* Called after the user has reviewed all years.
* Applies manual overrides; tracks with an empty year are excluded from the PDF.
*/
@PostMapping("/confirm")
public Map<String, String> confirm(@RequestBody GenerateRequest req) {
GenerationJob job = jobStore.get(req.jobId())
.orElseThrow(() -> new IllegalArgumentException("Job nicht gefunden: " + req.jobId()));
if (job.phase != GenerationJob.Phase.REVIEW_READY) {
throw new IllegalStateException("Job ist nicht im Review-Status.");
}
Map<String, String> yearOverrides = req.years() != null ? req.years() : Map.of();
Map<String, String> artistOverrides = req.artists() != null ? req.artists() : Map.of();
Map<String, String> titleOverrides = req.titles() != null ? req.titles() : Map.of();
List<Track> confirmed = new ArrayList<>();
for (Track t : job.tracks) {
String year = yearOverrides.getOrDefault(t.id(), t.releaseYear()).trim();
if (!year.isEmpty()) {
String artist = artistOverrides.getOrDefault(t.id(), t.artist()).trim();
String title = titleOverrides.getOrDefault(t.id(), t.title()).trim();
confirmed.add(new Track(t.id(), title, artist, year, t.streamingUrl()));
}
}
if (confirmed.isEmpty()) {
job.errorMessage = "Keine Karten zum Erstellen alle Titel ohne Jahr wurden ausgeschlossen.";
job.phase = GenerationJob.Phase.ERROR;
return Map.of("jobId", job.id);
}
CompletableFuture.runAsync(() -> {
try {
buildPdf(job, confirmed);
} catch (Exception e) {
log.warn("PDF generation failed for job {}: {}", job.id, e.getMessage());
job.errorMessage = e.getMessage();
job.phase = GenerationJob.Phase.ERROR;
}
});
return Map.of("jobId", job.id);
}
/** Returns the current status of a job for polling. */
@GetMapping("/status")
public Map<String, Object> status(@RequestParam(name = "jobId") String jobId) {
GenerationJob job = jobStore.get(jobId)
.orElseThrow(() -> new IllegalArgumentException("Job nicht gefunden: " + jobId));
Map<String, Object> resp = new HashMap<>();
resp.put("phase", job.phase.name());
resp.put("done", job.done);
resp.put("total", job.total);
resp.put("ready", job.phase == GenerationJob.Phase.DONE);
resp.put("error", job.errorMessage != null ? job.errorMessage : "");
if (job.phase == GenerationJob.Phase.REVIEW_READY && job.tracks != null) {
List<Map<String, String>> tracks = job.tracks.stream()
.map(t -> Map.of(
"id", t.id(),
"artist", t.artist(),
"title", t.title(),
"year", t.releaseYear(),
"streamingUrl", t.streamingUrl()))
.toList();
resp.put("tracks", tracks);
}
return resp;
}
/** Generates a QR code PNG for the given URL (used by the play dialog). */
@GetMapping("/qr")
public void qr(@RequestParam("url") String url, HttpServletResponse response) throws IOException {
BufferedImage img = qrGenerator.generate(url, 200);
response.setContentType("image/png");
response.setHeader("Cache-Control", "public, max-age=86400");
ImageIO.write(img, "png", response.getOutputStream());
}
/** Looks up a release year for a single track via MusicBrainz (used by the per-row "Prüfen" button). */
@GetMapping("/check-year")
public Map<String, String> checkYear(@RequestParam(name = "artist") String artist,
@RequestParam(name = "title") String title) {
return Map.of("year", musicBrainz.fetchYear(artist, title));
}
/** Cancels a running job. Safe to call at any phase. */
@PostMapping("/cancel")
public Map<String, String> cancel(@RequestParam(name = "jobId") String jobId) {
jobStore.get(jobId).ifPresent(job -> {
job.phase = GenerationJob.Phase.CANCELLED;
jobStore.remove(jobId);
});
return Map.of("jobId", jobId);
}
/** Streams the finished PDF and removes the job from the store. */
@GetMapping("/download")
public void download(@RequestParam(name = "jobId") String jobId,
HttpServletResponse response) throws IOException {
GenerationJob job = jobStore.get(jobId)
.orElseThrow(() -> new IllegalArgumentException("Job nicht gefunden oder bereits abgelaufen."));
if (job.phase != GenerationJob.Phase.DONE) {
response.sendError(HttpServletResponse.SC_CONFLICT, "Job ist noch nicht fertig.");
return;
}
byte[] pdf = job.pdf;
jobStore.remove(jobId);
response.setContentType(MediaType.APPLICATION_PDF_VALUE);
response.setHeader("Content-Disposition", "attachment; filename=\"" + job.filename + "\"");
response.setContentLength(pdf.length);
response.getOutputStream().write(pdf);
}
private void buildPdf(GenerationJob job, List<Track> tracks) throws IOException {
job.phase = GenerationJob.Phase.GENERATING;
job.done = 0;
job.total = tracks.size();
byte[] pdf = pdfGenerator.generate(job.playlistTitle, tracks, n -> job.done = n);
job.filename = job.playlistTitle.replaceAll("[^a-zA-Z0-9_\\-]", "_").toLowerCase() + ".pdf";
job.pdf = pdf;
job.phase = GenerationJob.Phase.DONE;
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.libredeck.web.controller;
import java.util.Map;
public record GenerateRequest(
String jobId,
Map<String, String> years, // trackId → year (empty = exclude card)
Map<String, String> artists, // trackId → artist override (optional)
Map<String, String> titles // trackId → title override (optional)
) {}

View File

@@ -0,0 +1,15 @@
package de.oaa.libredeck.web.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@RequiredArgsConstructor
public class PlaylistController {
@GetMapping("/")
public String index() {
return "index";
}
}

View File

@@ -0,0 +1,132 @@
package de.oaa.libredeck.web.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.libredeck.web.config.SpotifyConfig;
import de.oaa.libredeck.web.service.spotify.SpotifyTokenStore;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@Slf4j
@Controller
@RequestMapping("/spotify")
@RequiredArgsConstructor
public class SpotifyAuthController {
private static final String AUTH_URL = "https://accounts.spotify.com/authorize";
private static final String TOKEN_URL = "https://accounts.spotify.com/api/token";
private static final String SCOPE = "playlist-read-private playlist-read-collaborative";
private final SpotifyConfig config;
private final SpotifyTokenStore tokenStore;
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
/** Redirects the browser to Spotify's authorization page. */
@GetMapping("/auth")
public String auth(@RequestParam(name = "returnUrl", defaultValue = "") String returnUrl,
HttpServletRequest request) {
String redirectUri = buildRedirectUri(request);
// Encode returnUrl + redirectUri together in state so callback can use both
String state = returnUrl + "|" + redirectUri;
String url = AUTH_URL
+ "?client_id=" + enc(config.getClientId())
+ "&response_type=code"
+ "&redirect_uri=" + enc(redirectUri)
+ "&scope=" + enc(SCOPE)
+ "&state=" + enc(state);
return "redirect:" + url;
}
/** Spotify redirects here after the user grants access. */
@GetMapping("/callback")
public String callback(@RequestParam(name = "code", required = false) String code,
@RequestParam(name = "error", required = false) String error,
@RequestParam(name = "state", defaultValue = "") String state) {
if (error != null || code == null) {
log.warn("Spotify OAuth error: {}", error);
return "redirect:/?spotifyError=" + enc(error != null ? error : "unknown");
}
// state = "returnUrl|redirectUri"
String[] parts = state.split("\\|", 2);
String returnUrl = parts[0];
String redirectUri = parts.length > 1 ? parts[1] : config.getRedirectUri();
try {
String credentials = Base64.getEncoder().encodeToString(
(config.getClientId() + ":" + config.getClientSecret()).getBytes(StandardCharsets.UTF_8));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(TOKEN_URL))
.header("Authorization", "Basic " + credentials)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(
"grant_type=authorization_code"
+ "&code=" + enc(code)
+ "&redirect_uri=" + enc(redirectUri)))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
JsonNode json = mapper.readTree(resp.body());
if (json.has("error")) {
log.warn("Spotify token error: {}", resp.body());
return "redirect:/?spotifyError=" + enc(json.path("error").asText("token_error"));
}
tokenStore.setAccessToken(json.path("access_token").asText());
tokenStore.setRefreshToken(json.path("refresh_token").asText(""));
int expiresIn = json.path("expires_in").asInt(3600);
tokenStore.setExpiresAt(System.currentTimeMillis() + (expiresIn - 60) * 1000L);
log.info("Spotify token acquired for session, expires in {}s", expiresIn);
} catch (Exception e) {
log.error("Spotify token exchange failed", e);
return "redirect:/?spotifyError=token_exchange_failed";
}
if (!returnUrl.isBlank()) {
return "redirect:/?spotifyUrl=" + enc(returnUrl) + "#generator";
}
return "redirect:/#generator";
}
/** Derives the callback URI from the current request — works for localhost and production without config. */
private static String buildRedirectUri(HttpServletRequest request) {
boolean behindProxy = request.getHeader("X-Forwarded-Proto") != null;
String scheme = behindProxy ? request.getHeader("X-Forwarded-Proto") : request.getScheme();
String host = request.getHeader("X-Forwarded-Host");
if (host == null) host = request.getServerName();
String base;
if (behindProxy) {
// External port is managed by the proxy; never append it
base = scheme + "://" + host;
} else {
int port = request.getServerPort();
boolean defaultPort = (scheme.equals("https") && port == 443) || (scheme.equals("http") && port == 80);
base = scheme + "://" + host + (defaultPort ? "" : ":" + port);
}
return base + "/spotify/callback";
}
private static String enc(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,47 @@
package de.oaa.libredeck.web.controller;
import de.oaa.libredeck.web.model.PlaylistSummary;
import de.oaa.libredeck.web.service.spotify.SpotifyApiClient;
import de.oaa.libredeck.web.service.spotify.SpotifyAuthRequiredException;
import de.oaa.libredeck.web.service.spotify.SpotifyTokenRefresher;
import de.oaa.libredeck.web.service.spotify.SpotifyTokenStore;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/spotify")
@RequiredArgsConstructor
public class SpotifyController {
private final SpotifyTokenStore tokenStore;
private final SpotifyApiClient apiClient;
private final SpotifyTokenRefresher tokenRefresher;
@GetMapping("/connected")
public Map<String, Boolean> connected() {
if (!tokenStore.hasValidToken()) tokenRefresher.tryRefresh();
return Map.of("connected", tokenStore.hasValidToken());
}
@GetMapping("/playlists")
public ResponseEntity<?> playlists() {
if (!tokenStore.hasValidToken() && !tokenRefresher.tryRefresh()) {
return ResponseEntity.status(401).body(Map.of("error", "not_authenticated"));
}
try {
List<PlaylistSummary> list = apiClient.fetchUserPlaylists();
return ResponseEntity.ok(list);
} catch (SpotifyAuthRequiredException e) {
return ResponseEntity.status(401).body(Map.of("error", "not_authenticated"));
} catch (IOException e) {
return ResponseEntity.status(502).body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,165 @@
package de.oaa.libredeck.web.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.libredeck.web.config.TidalConfig;
import de.oaa.libredeck.web.service.tidal.TidalTokenStore;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
@Slf4j
@Controller
@RequestMapping("/tidal")
@RequiredArgsConstructor
public class TidalAuthController {
private static final String AUTH_URL = "https://login.tidal.com/authorize";
private static final String TOKEN_URL = "https://auth.tidal.com/v1/oauth2/token";
private static final String SCOPE = "playlists.read collection.read user.read";
private final TidalConfig config;
private final TidalTokenStore tokenStore;
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
@GetMapping("/auth")
public String auth(@RequestParam(name = "returnUrl", defaultValue = "") String returnUrl,
HttpServletRequest request) {
String redirectUri = buildRedirectUri(request);
String codeVerifier = generateCodeVerifier();
String codeChallenge = generateCodeChallenge(codeVerifier);
tokenStore.setCodeVerifier(codeVerifier);
tokenStore.setRedirectUri(redirectUri);
String url = AUTH_URL
+ "?client_id=" + enc(config.getClientId())
+ "&response_type=code"
+ "&redirect_uri=" + enc(redirectUri)
+ "&scope=" + enc(SCOPE)
+ "&code_challenge=" + enc(codeChallenge)
+ "&code_challenge_method=S256"
+ "&state=" + enc(returnUrl);
return "redirect:" + url;
}
@GetMapping("/callback")
public String callback(@RequestParam(name = "code", required = false) String code,
@RequestParam(name = "error", required = false) String error,
@RequestParam(name = "state", defaultValue = "") String state) {
if (error != null || code == null) {
log.warn("Tidal OAuth error: {}", error);
return "redirect:/?tidalError=" + enc(error != null ? error : "unknown");
}
String returnUrl = state; // state now carries only returnUrl
String redirectUri = tokenStore.getRedirectUri();
String codeVerifier = tokenStore.getCodeVerifier();
if (codeVerifier == null || codeVerifier.isBlank()) {
log.warn("Tidal callback: missing code_verifier in session");
return "redirect:/?tidalError=missing_verifier";
}
try {
String body = "grant_type=authorization_code"
+ "&code=" + enc(code)
+ "&client_id=" + enc(config.getClientId())
+ "&redirect_uri=" + enc(redirectUri)
+ "&code_verifier=" + enc(codeVerifier);
if (!config.getClientSecret().isBlank()) {
body += "&client_secret=" + enc(config.getClientSecret());
}
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(TOKEN_URL))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
JsonNode json = mapper.readTree(resp.body());
if (json.has("error") || resp.statusCode() >= 400) {
log.warn("Tidal token error ({}): {}", resp.statusCode(), resp.body());
return "redirect:/?tidalError=" + enc(json.path("error").asText("token_error"));
}
tokenStore.setAccessToken(json.path("access_token").asText());
tokenStore.setRefreshToken(json.path("refresh_token").asText(""));
int expiresIn = json.path("expires_in").asInt(3600);
tokenStore.setExpiresAt(System.currentTimeMillis() + (expiresIn - 60) * 1000L);
tokenStore.setCodeVerifier(null);
// Store country from token response if present
String country = json.path("user").path("countryCode").asText("");
if (country.isBlank()) country = config.getCountryCode();
tokenStore.setCountryCode(country);
log.info("Tidal token acquired, country={}, expires in {}s", country, expiresIn);
} catch (Exception e) {
log.error("Tidal token exchange failed", e);
return "redirect:/?tidalError=token_exchange_failed";
}
if (!returnUrl.isBlank()) {
return "redirect:/?tidalUrl=" + enc(returnUrl) + "#generator";
}
return "redirect:/#generator";
}
private static String buildRedirectUri(HttpServletRequest request) {
boolean behindProxy = request.getHeader("X-Forwarded-Proto") != null;
String scheme = behindProxy ? request.getHeader("X-Forwarded-Proto") : request.getScheme();
String host = request.getHeader("X-Forwarded-Host");
if (host == null) host = request.getServerName();
String base;
if (behindProxy) {
base = scheme + "://" + host;
} else {
int port = request.getServerPort();
boolean defaultPort = ("https".equals(scheme) && port == 443) || ("http".equals(scheme) && port == 80);
base = scheme + "://" + host + (defaultPort ? "" : ":" + port);
}
return base + "/tidal/callback";
}
private static String generateCodeVerifier() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private static String generateCodeChallenge(String verifier) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (Exception e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
private static String enc(String s) {
if (s == null) return "";
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,44 @@
package de.oaa.libredeck.web.controller;
import de.oaa.libredeck.web.model.PlaylistSummary;
import de.oaa.libredeck.web.service.tidal.TidalApiClient;
import de.oaa.libredeck.web.service.tidal.TidalAuthRequiredException;
import de.oaa.libredeck.web.service.tidal.TidalTokenStore;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/tidal")
@RequiredArgsConstructor
public class TidalController {
private final TidalTokenStore tokenStore;
private final TidalApiClient apiClient;
@GetMapping("/connected")
public Map<String, Boolean> connected() {
return Map.of("connected", tokenStore.hasValidToken());
}
@GetMapping("/playlists")
public ResponseEntity<?> playlists() {
if (!tokenStore.hasValidToken()) {
return ResponseEntity.status(401).body(Map.of("error", "not_authenticated"));
}
try {
List<PlaylistSummary> list = apiClient.fetchUserPlaylists();
return ResponseEntity.ok(list);
} catch (TidalAuthRequiredException e) {
return ResponseEntity.status(401).body(Map.of("error", "not_authenticated"));
} catch (IOException e) {
return ResponseEntity.status(502).body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,3 @@
package de.oaa.libredeck.web.model;
public record OAuthToken(String accessToken, long expiresIn) {}

View File

@@ -0,0 +1,3 @@
package de.oaa.libredeck.web.model;
public record PlaylistSummary(String id, String title, int trackCount, String coverUrl) {}

View File

@@ -0,0 +1,3 @@
package de.oaa.libredeck.web.model;
public record Track(String id, String title, String artist, String releaseYear, String streamingUrl) {}

View File

@@ -0,0 +1,26 @@
package de.oaa.libredeck.web.service;
import java.util.List;
import de.oaa.libredeck.web.model.Track;
public class GenerationJob {
public enum Phase { FETCHING, LOOKUP, REVIEW_READY, GENERATING, DONE, ERROR, CANCELLED }
public final String id;
public final long createdAt = System.currentTimeMillis();
public volatile Phase phase = Phase.FETCHING;
public volatile int done = 0;
public volatile int total = 0;
public volatile String errorMessage = null;
public volatile byte[] pdf = null;
public volatile String filename = null;
public volatile String playlistTitle = null;
public volatile List<Track> tracks = null;
public GenerationJob(String id) {
this.id = id;
}
}

View File

@@ -0,0 +1,35 @@
package de.oaa.libredeck.web.service;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class JobStore {
private static final long TTL_MS = 10 * 60 * 1000; // 10 minutes
private final ConcurrentHashMap<String, GenerationJob> jobs = new ConcurrentHashMap<>();
public GenerationJob create() {
evictExpired();
GenerationJob job = new GenerationJob(UUID.randomUUID().toString());
jobs.put(job.id, job);
return job;
}
public Optional<GenerationJob> get(String id) {
return Optional.ofNullable(jobs.get(id));
}
public void remove(String id) {
jobs.remove(id);
}
private void evictExpired() {
long cutoff = System.currentTimeMillis() - TTL_MS;
jobs.entrySet().removeIf(e -> e.getValue().createdAt < cutoff);
}
}

View File

@@ -0,0 +1,108 @@
package de.oaa.libredeck.web.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
@Slf4j
@Component
public class MusicBrainzClient {
private static final String API_BASE = "https://musicbrainz.org/ws/2";
private static final String USER_AGENT = "LibreDeck/0.1 (https://github.com/libredeck)";
private static final long RATE_LIMIT = 1_100; // 1.1s stays within the 1 req/s limit
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
private long lastRequestTime = 0;
/**
* Looks up the earliest release year for a given recording.
* Returns an empty string when no confident match is found.
* This method is synchronized to enforce the MusicBrainz rate limit.
*/
public synchronized String fetchYear(String artist, String title) {
try {
rateLimit();
String query = "recording:\"" + escapeLucene(title) + "\""
+ " AND artist:\"" + escapeLucene(artist) + "\"";
String url = API_BASE + "/recording?query="
+ URLEncoder.encode(query, StandardCharsets.UTF_8)
+ "&fmt=json&limit=5";
String body = get(url);
JsonNode root = mapper.readTree(body);
JsonNode recordings = root.path("recordings");
if (!recordings.isArray()) return "";
String oldest = "";
for (JsonNode rec : recordings) {
if (rec.path("score").asInt(0) < 75) break; // sorted by score desc
String date = rec.path("first-release-date").asText("");
if (date.length() >= 4) {
String year = date.substring(0, 4);
if (oldest.isEmpty() || year.compareTo(oldest) < 0) oldest = year;
}
}
return oldest;
} catch (Exception e) {
log.warn("MusicBrainz lookup failed for [{} {}]: {}", artist, title, e.getMessage());
return "";
}
}
private void rateLimit() throws InterruptedException {
long wait = RATE_LIMIT - (System.currentTimeMillis() - lastRequestTime);
if (wait > 0) Thread.sleep(wait);
lastRequestTime = System.currentTimeMillis();
}
private String get(String url) throws IOException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 503) {
throw new IOException("MusicBrainz rate limit exceeded (503)");
}
if (response.statusCode() != 200) {
throw new IOException("MusicBrainz error " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Request interrupted", e);
}
}
private String escapeLucene(String input) {
// Escape Lucene special characters relevant to MusicBrainz queries
return input.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("(", "\\(").replace(")", "\\)")
.replace("[", "\\[").replace("]", "\\]")
.replace("{", "\\{").replace("}", "\\}")
.replace(":", "\\:")
.replace("^", "\\^")
.replace("~", "\\~")
.replace("*", "\\*")
.replace("?", "\\?");
}
}

View File

@@ -0,0 +1,57 @@
package de.oaa.libredeck.web.service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class TrackNormalizer {
private static final Pattern FEAT_PARENS = Pattern.compile(
"\\s*[\\(\\[]\\s*(?:feat\\.?|ft\\.?|featuring|with)\\s+([^\\)\\]]+)[\\)\\]]",
Pattern.CASE_INSENSITIVE);
private static final Pattern FEAT_DASH = Pattern.compile(
"\\s*[-]\\s*(?:feat\\.?|ft\\.?|featuring)\\s+(.+)$",
Pattern.CASE_INSENSITIVE);
private static final String ANNOTATION_KEYWORDS =
"remaster(ed)?|album version|single version|radio edit|radio version|" +
"extended version|original version|soundtrack|from\\b|" +
"official|live|hd|hq|4k|upgrade|with\\s+lyrics|lyrics?|promo|music\\s+video|lyric\\s+video|version";
private static final Pattern ANNOTATION_PARENS = Pattern.compile(
"\\s*[\\(\\[][^\\)\\]]*\\b(?:" + ANNOTATION_KEYWORDS + ")[^\\)\\]]*[\\)\\]]",
Pattern.CASE_INSENSITIVE);
private static final Pattern ANNOTATION_DASH = Pattern.compile(
"\\s*[-]\\s*[^(\\[]*?\\b(?:" + ANNOTATION_KEYWORDS + ").*$",
Pattern.CASE_INSENSITIVE);
// *annotation text* style (e.g. "*in the description box*")
private static final Pattern ANNOTATION_ASTERISK = Pattern.compile(
"\\s*\\*[^*]+\\*", Pattern.CASE_INSENSITIVE);
// trailing resolution tokens like "WIDESCREEN 720p", "720p", "1080p"
private static final Pattern RESOLUTION_SUFFIX = Pattern.compile(
"\\s+(?:WIDESCREEN\\s+)?\\d{3,4}p$", Pattern.CASE_INSENSITIVE);
private TrackNormalizer() {}
public static String cleanTitle(String title) {
title = ANNOTATION_ASTERISK.matcher(title).replaceAll("");
title = ANNOTATION_PARENS.matcher(title).replaceAll("");
title = ANNOTATION_DASH.matcher(title).replaceAll("");
title = RESOLUTION_SUFFIX.matcher(title).replaceAll("");
return title.trim();
}
/** Moves feat./ft./featuring info from the title into the artist string. */
public static String[] extractFeaturing(String title, String artist) {
Matcher m = FEAT_PARENS.matcher(title);
if (!m.find()) m = FEAT_DASH.matcher(title);
if (!m.find()) return new String[]{title, artist};
String featArtist = m.group(1).trim();
String cleanedTitle = m.replaceAll("").trim();
if (!artist.toLowerCase().contains(featArtist.toLowerCase())) {
artist = artist + " feat. " + featArtist;
}
return new String[]{cleanedTitle, artist};
}
}

View File

@@ -0,0 +1,152 @@
package de.oaa.libredeck.web.service.applemusic;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.libredeck.web.model.PlaylistSummary;
import de.oaa.libredeck.web.model.Track;
import de.oaa.libredeck.web.service.TrackNormalizer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class AppleMusicApiClient {
private static final String API_BASE = "https://api.music.apple.com/v1";
private static final long RATE_LIMIT = 150;
private static final ThreadLocal<String> MUT_OVERRIDE = new ThreadLocal<>();
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
private final AppleMusicTokenGenerator tokenGen;
private final AppleMusicTokenStore tokenStore;
private long lastRequest = 0;
public void pinMusicUserTokenForThread(String token) { MUT_OVERRIDE.set(token); }
public void clearThreadToken() { MUT_OVERRIDE.remove(); }
public String fetchCatalogPlaylistTitle(String storefront, String playlistId) throws IOException {
JsonNode root = mapper.readTree(get(API_BASE + "/catalog/" + storefront + "/playlists/" + playlistId, false));
return root.path("data").path(0).path("attributes").path("name").asText("Playlist " + playlistId);
}
public List<Track> fetchCatalogTracks(String storefront, String playlistId) throws IOException {
return fetchTrackPages(API_BASE + "/catalog/" + storefront + "/playlists/" + playlistId + "/tracks", false);
}
public String fetchLibraryPlaylistTitle(String playlistId) throws IOException {
JsonNode root = mapper.readTree(get(API_BASE + "/me/library/playlists/" + playlistId, true));
return root.path("data").path(0).path("attributes").path("name").asText("Playlist " + playlistId);
}
public List<Track> fetchLibraryTracks(String playlistId) throws IOException {
return fetchTrackPages(API_BASE + "/me/library/playlists/" + playlistId + "/tracks", true);
}
public List<PlaylistSummary> fetchUserPlaylists() throws IOException {
List<PlaylistSummary> result = new ArrayList<>();
String url = API_BASE + "/me/library/playlists?limit=100";
while (url != null) {
JsonNode root = mapper.readTree(get(url, true));
for (JsonNode item : root.path("data")) {
String id = item.path("id").asText("");
if (id.isEmpty()) continue;
String name = item.path("attributes").path("name").asText("");
String cover = resolveCoverUrl(item.path("attributes").path("artwork"));
result.add(new PlaylistSummary(id, name, 0, cover));
}
url = nextUrl(root);
}
return result;
}
// ── Private helpers ──────────────────────────────────────────────────────
private List<Track> fetchTrackPages(String startUrl, boolean requireMut) throws IOException {
List<Track> result = new ArrayList<>();
String url = startUrl;
while (url != null) {
JsonNode root = mapper.readTree(get(url, requireMut));
for (JsonNode node : root.path("data")) {
JsonNode attrs = node.path("attributes");
String id = node.path("id").asText("");
if (id.isEmpty()) continue;
String rawTitle = attrs.path("name").asText("");
String rawArtist = attrs.path("artistName").asText("");
String date = attrs.path("releaseDate").asText("");
String year = date.length() >= 4 ? date.substring(0, 4) : "";
String trackUrl = attrs.path("url").asText("https://music.apple.com/album/id/" + id);
String[] ta = TrackNormalizer.extractFeaturing(TrackNormalizer.cleanTitle(rawTitle), rawArtist);
result.add(new Track(id, ta[0], ta[1], year, trackUrl));
}
url = nextUrl(root);
}
return result;
}
private synchronized String get(String url, boolean requireMut) throws IOException {
String devToken = tokenGen.getDeveloperToken();
String mut = MUT_OVERRIDE.get();
if (mut == null) mut = tokenStore.getMusicUserToken();
if (requireMut && (mut == null || mut.isBlank())) throw new AppleMusicAuthRequiredException();
rateLimitWait();
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + devToken)
.header("Accept-Language", "en-US");
if (mut != null && !mut.isBlank()) builder.header("Music-User-Token", mut);
try {
HttpResponse<String> resp = http.send(builder.GET().build(), HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 401 || resp.statusCode() == 403) throw new AppleMusicAuthRequiredException();
if (resp.statusCode() != 200) {
throw new IOException("Apple Music API error " + resp.statusCode() + ": " + resp.body());
}
return resp.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Request interrupted", e);
}
}
private void rateLimitWait() {
long wait = RATE_LIMIT - (System.currentTimeMillis() - lastRequest);
if (wait > 0) {
try { Thread.sleep(wait); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
lastRequest = System.currentTimeMillis();
}
private static String nextUrl(JsonNode root) {
JsonNode next = root.path("next");
if (next.isMissingNode() || next.isNull()) return null;
String rel = next.asText("");
if (rel.isBlank()) return null;
return rel.startsWith("http") ? rel : "https://api.music.apple.com" + rel;
}
private static String resolveCoverUrl(JsonNode artwork) {
if (artwork.isMissingNode() || artwork.isNull()) return "";
String tpl = artwork.path("url").asText("");
if (tpl.isBlank()) return "";
int w = artwork.path("width").asInt(300);
int h = artwork.path("height").asInt(300);
int size = Math.min(w, Math.min(h, 300));
return tpl.replace("{w}", String.valueOf(size)).replace("{h}", String.valueOf(size));
}
}

View File

@@ -0,0 +1,9 @@
package de.oaa.libredeck.web.service.applemusic;
public class AppleMusicAuthRequiredException extends RuntimeException {
private static final long serialVersionUID = -2999990578098773506L;
public AppleMusicAuthRequiredException() {
super("Apple Music authentication required");
}
}

View File

@@ -0,0 +1,69 @@
package de.oaa.libredeck.web.service.applemusic;
import de.oaa.libredeck.web.model.Track;
import de.oaa.libredeck.web.service.streaming.StreamingProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
@RequiredArgsConstructor
public class AppleMusicProvider implements StreamingProvider {
// https://music.apple.com/us/playlist/my-playlist/pl.xxxxxxxxxxxxxxxx
private static final Pattern CATALOG_URL = Pattern.compile(
"music\\.apple\\.com/([a-z]{2})/playlist/[^/]*/([A-Za-z0-9.]+)");
// Synthetic URL generated by the playlist picker for library playlists
private static final Pattern LIBRARY_URL = Pattern.compile(
"music\\.apple\\.com/library/playlists/([A-Za-z0-9.]+)");
private final AppleMusicApiClient apiClient;
private final AppleMusicTokenGenerator tokenGen;
@Override public String getId() { return "applemusic"; }
@Override public String getDisplayName() { return "Apple Music"; }
@Override
public boolean supportsUrl(String url) {
if (url == null || !tokenGen.isConfigured()) return false;
return CATALOG_URL.matcher(url).find() || LIBRARY_URL.matcher(url).find();
}
/**
* Returns "catalog:{storefront}:{id}" or "library:{id}".
* This encoding lets the API client know which endpoint to use.
*/
@Override
public String extractPlaylistId(String url) {
Matcher m = CATALOG_URL.matcher(url);
if (m.find()) return "catalog:" + m.group(1) + ":" + m.group(2);
m = LIBRARY_URL.matcher(url);
if (m.find()) return "library:" + m.group(1);
throw new IllegalArgumentException("Not a valid Apple Music playlist URL: " + url);
}
@Override
public String fetchPlaylistTitle(String playlistId) throws IOException {
String[] p = playlistId.split(":", 3);
return switch (p[0]) {
case "catalog" -> apiClient.fetchCatalogPlaylistTitle(p[1], p[2]);
case "library" -> apiClient.fetchLibraryPlaylistTitle(p[1]);
default -> throw new IllegalArgumentException("Invalid id: " + playlistId);
};
}
@Override
public List<Track> fetchTracks(String playlistId) throws IOException {
String[] p = playlistId.split(":", 3);
return switch (p[0]) {
case "catalog" -> apiClient.fetchCatalogTracks(p[1], p[2]);
case "library" -> apiClient.fetchLibraryTracks(p[1]);
default -> throw new IllegalArgumentException("Invalid id: " + playlistId);
};
}
}

View File

@@ -0,0 +1,77 @@
package de.oaa.libredeck.web.service.applemusic;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import de.oaa.libredeck.web.config.AppleMusicConfig;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
@Slf4j
@Component
@RequiredArgsConstructor
public class AppleMusicTokenGenerator {
private static final long TOKEN_LIFETIME = 3600L; // 1 hour in seconds
private final AppleMusicConfig config;
private volatile String cachedToken;
private volatile long tokenExpiry;
public boolean isConfigured() {
return !config.getTeamId().isBlank()
&& !config.getKeyId().isBlank()
&& !config.getPrivateKey().isBlank();
}
public String getDeveloperToken() {
if (cachedToken != null && Instant.now().getEpochSecond() < tokenExpiry - 60) {
return cachedToken;
}
try {
cachedToken = buildToken();
tokenExpiry = Instant.now().getEpochSecond() + TOKEN_LIFETIME;
return cachedToken;
} catch (Exception e) {
throw new RuntimeException("Failed to generate Apple Music developer token", e);
}
}
private String buildToken() throws Exception {
String headerJson = "{\"alg\":\"ES256\",\"kid\":\"" + config.getKeyId() + "\"}";
long now = Instant.now().getEpochSecond();
String payloadJson = "{\"iss\":\"" + config.getTeamId() + "\",\"iat\":" + now
+ ",\"exp\":" + (now + TOKEN_LIFETIME) + "}";
String headerB64 = b64url(headerJson.getBytes(StandardCharsets.UTF_8));
String payloadB64 = b64url(payloadJson.getBytes(StandardCharsets.UTF_8));
String sigInput = headerB64 + "." + payloadB64;
Signature sig = Signature.getInstance("SHA256withECDSAinP1363Format");
sig.initSign(loadPrivateKey(config.getPrivateKey()));
sig.update(sigInput.getBytes(StandardCharsets.UTF_8));
return sigInput + "." + b64url(sig.sign());
}
private static PrivateKey loadPrivateKey(String pem) throws Exception {
String stripped = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
byte[] keyBytes = Base64.getDecoder().decode(stripped);
return KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
}
private static String b64url(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
}

View File

@@ -0,0 +1,20 @@
package de.oaa.libredeck.web.service.applemusic;
import lombok.Getter;
import lombok.Setter;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;
@Getter
@Setter
@Component
@SessionScope(proxyMode = ScopedProxyMode.TARGET_CLASS)
public class AppleMusicTokenStore {
private String musicUserToken;
public boolean hasToken() {
return musicUserToken != null && !musicUserToken.isBlank();
}
}

View File

@@ -0,0 +1,128 @@
package de.oaa.libredeck.web.service.deezer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.libredeck.web.model.Track;
import de.oaa.libredeck.web.service.TrackNormalizer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
public class DeezerApiClient {
private static final String API_BASE = "https://api.deezer.com";
private static final long RATE_LIMIT = 150; // ms stays safely under Deezer's 50 req/5s quota
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
private long lastRequestTime = 0;
public String fetchPlaylistTitle(String playlistId) throws IOException {
JsonNode root = mapper.readTree(get(API_BASE + "/playlist/" + playlistId));
return root.path("title").asText("Playlist " + playlistId);
}
/** Fetches lite track stubs from the playlist (no year resolved here). */
public List<Track> fetchTracks(String playlistId) throws IOException {
List<Track> result = new ArrayList<>();
String url = API_BASE + "/playlist/" + playlistId + "/tracks?limit=100";
while (url != null) {
JsonNode root = mapper.readTree(get(url));
JsonNode data = root.path("data");
if (!data.isArray()) throw new IOException("Unerwartetes Deezer-Response-Format (kein data-Array)");
for (JsonNode node : data) {
String rawTitle = node.path("title").asText();
String rawArtist = node.path("artist").path("name").asText("");
String[] ta = TrackNormalizer.extractFeaturing(TrackNormalizer.cleanTitle(rawTitle), rawArtist);
result.add(new Track(
node.get("id").asText(),
ta[0],
ta[1],
"",
node.path("link").asText("https://www.deezer.com/track/" + node.get("id").asText())
));
}
JsonNode next = root.path("next");
url = next.isMissingNode() || next.isNull() ? null : next.asText();
}
return result;
}
/**
* Fetches the full track object and returns the oldest release year found
* by comparing the track's own release_date with its album's release_date.
* Returns an empty string if no date is available.
*/
public String fetchTrackYear(String trackId) {
try {
JsonNode full = mapper.readTree(get(API_BASE + "/track/" + trackId));
String trackDate = full.path("release_date").asText("");
String albumDate = full.path("album").path("release_date").asText("");
String trackYear = trackDate.length() >= 4 ? trackDate.substring(0, 4) : "";
String albumYear = albumDate.length() >= 4 ? albumDate.substring(0, 4) : "";
if (trackYear.isEmpty() && albumYear.isEmpty()) return "";
if (trackYear.isEmpty()) return albumYear;
if (albumYear.isEmpty()) return trackYear;
return trackYear.compareTo(albumYear) <= 0 ? trackYear : albumYear;
} catch (IOException e) {
log.warn("Could not fetch track year for {}: {}", trackId, e.getMessage());
return "";
}
}
private synchronized String get(String url) throws IOException {
long wait = RATE_LIMIT - (System.currentTimeMillis() - lastRequestTime);
if (wait > 0) {
try { Thread.sleep(wait); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Request interrupted", e);
}
}
lastRequestTime = System.currentTimeMillis();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Deezer API error " + response.statusCode() + ": " + response.body());
}
String body = response.body();
JsonNode root = mapper.readTree(body);
JsonNode error = root.path("error");
if (!error.isMissingNode() && !error.isNull()) {
String msg = error.path("message").asText("Unbekannter Fehler");
int code = error.path("code").asInt(0);
throw new IOException("Deezer: " + msg + " (code " + code + ")");
}
return body;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Request interrupted", e);
}
}
// -------------------------------------------------------------------------
// OAuth methods commented out until Deezer re-enables app registration.
// -------------------------------------------------------------------------
// private static final String TOKEN_URL = "https://connect.deezer.com/oauth/access_token.php";
//
// public OAuthToken exchangeCode(String code, DeezerConfig config) throws IOException { ... }
// public List<PlaylistSummary> fetchPlaylists(String accessToken) throws IOException { ... }
}

View File

@@ -0,0 +1,82 @@
package de.oaa.libredeck.web.service.deezer;
import de.oaa.libredeck.web.model.Track;
import de.oaa.libredeck.web.service.streaming.StreamingProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
@RequiredArgsConstructor
public class DeezerProvider implements StreamingProvider {
// Matches e.g. https://www.deezer.com/playlist/1234 or .../de/playlist/1234
private static final Pattern PLAYLIST_URL = Pattern.compile(
"deezer\\.com(?:/[a-z]{2})?/playlist/(\\d+)"
);
private final DeezerApiClient apiClient;
@Override
public String getId() {
return "deezer";
}
@Override
public String getDisplayName() {
return "Deezer";
}
@Override
public boolean supportsUrl(String url) {
return url != null && PLAYLIST_URL.matcher(url).find();
}
@Override
public String extractPlaylistId(String url) {
Matcher m = PLAYLIST_URL.matcher(url);
if (!m.find()) throw new IllegalArgumentException("Not a valid Deezer playlist URL: " + url);
return m.group(1);
}
@Override
public String fetchPlaylistTitle(String playlistId) throws IOException {
return apiClient.fetchPlaylistTitle(playlistId);
}
@Override
public List<Track> fetchTracks(String playlistId) throws IOException {
return apiClient.fetchTracks(playlistId);
}
@Override
public String fetchTrackYear(String trackId) {
return apiClient.fetchTrackYear(trackId);
}
// -------------------------------------------------------------------------
// OAuth methods commented out until Deezer re-enables app registration.
// Re-enable together with AuthController and DeezerConfig when an app-id
// / app-secret is available.
// -------------------------------------------------------------------------
// @Override public String buildAuthorizationUrl(String state) {
// return "https://connect.deezer.com/oauth/auth.php"
// + "?app_id=" + config.getAppId()
// + "&redirect_uri=" + URLEncoder.encode(config.getRedirectUri(), UTF_8)
// + "&perms=basic_access,manage_library"
// + "&state=" + state;
// }
// @Override public OAuthToken exchangeCode(String code) throws IOException {
// return apiClient.exchangeCode(code, config);
// }
// @Override public List<PlaylistSummary> fetchPlaylists(String accessToken) throws IOException {
// return apiClient.fetchPlaylists(accessToken);
// }
// @Override public List<Track> fetchTracks(String accessToken, String playlistId) throws IOException {
// return apiClient.fetchTracks(playlistId); // token currently unused
// }
}

View File

@@ -0,0 +1,256 @@
package de.oaa.libredeck.web.service.pdf;
import lombok.RequiredArgsConstructor;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.springframework.stereotype.Component;
import de.oaa.libredeck.web.model.Track;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.function.IntConsumer;
@Component
@RequiredArgsConstructor
public class PdfCardGenerator {
private static final float PAGE_W = PDRectangle.A4.getWidth();
private static final float PAGE_H = PDRectangle.A4.getHeight();
private static final float MM_TO_PT = 72f / 25.4f;
private static final float CARD_SIZE = 40 * MM_TO_PT;
private static final float MARGIN = 5 * MM_TO_PT;
private static final int COLS = (int) ((PAGE_W - 2 * MARGIN) / CARD_SIZE); // 5
private static final int ROWS = (int) ((PAGE_H - 2 * MARGIN) / CARD_SIZE); // 7
private static final int CARDS_PER_PAGE = COLS * ROWS; // 35
// Brand colours (from logo: teal #1a9aaa, green #46c14a)
private static final Color C_TEAL = new Color(0x1a, 0x9a, 0xaa);
private static final Color C_TEAL_DARK = new Color(0x13, 0x6e, 0x7a);
private static final Color C_DIVIDER = new Color(0xc5, 0xe6, 0xea);
private static final Color C_ARTIST = new Color(0x55, 0x55, 0x55);
private static final Color C_BRAND = new Color(0xaa, 0xaa, 0xcc);
private final QrCodeGenerator qrGenerator;
/** Synchronous generation without progress reporting. */
public byte[] generate(String playlistTitle, List<Track> tracks) throws IOException {
return generate(playlistTitle, tracks, n -> {});
}
public byte[] generate(String playlistTitle, List<Track> tracks, IntConsumer onProgress) throws IOException {
try (PDDocument doc = new PDDocument()) {
int totalPages = (int) Math.ceil((double) tracks.size() / CARDS_PER_PAGE);
int processed = 0;
for (int page = 0; page < totalPages; page++) {
int from = page * CARDS_PER_PAGE;
int to = Math.min(from + CARDS_PER_PAGE, tracks.size());
List<Track> chunk = tracks.subList(from, to);
addFrontPage(doc, chunk, processed, onProgress);
addBackPage(doc, chunk);
processed += chunk.size();
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
doc.save(out);
return out.toByteArray();
}
}
private void addFrontPage(PDDocument doc, List<Track> chunk,
int offset, IntConsumer onProgress) throws IOException {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
for (int i = 0; i < chunk.size(); i++) {
Track track = chunk.get(i);
float[] pos = cardPosition(i, false);
float x = pos[0], y = pos[1];
fillCardBackground(cs, x, y);
drawCardBorder(cs, x, y);
// QR code — enlarged by the space the former outer border occupied
BufferedImage qrImage = qrGenerator.generate(track.streamingUrl(), 300);
PDImageXObject pdImage = LosslessFactory.createFromImage(doc, qrImage);
float qrSize = 30 * MM_TO_PT + 6f; // +6 pt absorbs the old 3 pt border on each side
float qrX = x + (CARD_SIZE - qrSize) / 2f;
float qrY = y + (CARD_SIZE - qrSize) / 2f;
cs.drawImage(pdImage, qrX, qrY, qrSize, qrSize);
// Teal rounded border — positioned to exactly wrap the white backing pill
float logoAspect = qrGenerator.logoAspect();
if (logoAspect > 0) {
int pillPadPx = QrCodeGenerator.logoPillPad(300);
float pillPad = pillPadPx * qrSize / 300f;
float logoW = qrSize * QrCodeGenerator.LOGO_RATIO;
float logoH = logoW * logoAspect;
float lx = qrX + (qrSize - logoW) / 2f;
float ly = qrY + (qrSize - logoH) / 2f;
setStrokeColor(cs, C_TEAL);
cs.setLineWidth(1.0f);
drawRoundedRect(cs, lx - pillPad, ly - pillPad,
logoW + 2 * pillPad, logoH + 2 * pillPad, 4f);
cs.stroke();
}
onProgress.accept(offset + i + 1);
}
}
}
private void addBackPage(PDDocument doc, List<Track> chunk) throws IOException {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDType1Font fontBold = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
PDType1Font fontNormal = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
for (int i = 0; i < chunk.size(); i++) {
Track track = chunk.get(i);
float[] pos = cardPosition(i, true);
float x = pos[0], y = pos[1];
fillCardBackground(cs, x, y);
drawCardBorder(cs, x, y);
// Year — large, teal-dark (shifted up to make room for 2-line artist)
String year = track.releaseYear();
float yearSize = 22f;
drawCenteredText(cs, fontBold, yearSize, year, C_TEAL_DARK,
x, y + CARD_SIZE - 12 * MM_TO_PT, CARD_SIZE);
// Divider — light teal
setStrokeColor(cs, C_DIVIDER);
cs.setLineWidth(0.5f);
float divY = y + CARD_SIZE - 15.5f * MM_TO_PT;
cs.moveTo(x + 3 * MM_TO_PT, divY);
cs.lineTo(x + CARD_SIZE - 3 * MM_TO_PT, divY);
cs.stroke();
// Artist — gray, up to 2 lines
renderWrappedText(cs, fontNormal, 9f, track.artist(), C_ARTIST,
x + 3 * MM_TO_PT, y + CARD_SIZE - 20 * MM_TO_PT,
CARD_SIZE - 6 * MM_TO_PT, 11f, 2);
// Title — teal, bold, up to 2 lines
renderWrappedText(cs, fontBold, 9f, track.title(), C_TEAL,
x + 3 * MM_TO_PT, y + CARD_SIZE - 30 * MM_TO_PT,
CARD_SIZE - 6 * MM_TO_PT, 11f, 2);
// Brand label
drawCenteredText(cs, fontNormal, 6f, "LibreDeck", C_BRAND, x, y + 3, CARD_SIZE);
}
}
}
// ── drawing helpers ──────────────────────────────────────────────────────
private void fillCardBackground(PDPageContentStream cs, float x, float y) throws IOException {
cs.setNonStrokingColor(Color.WHITE);
cs.addRect(x, y, CARD_SIZE, CARD_SIZE);
cs.fill();
}
private void drawCardBorder(PDPageContentStream cs, float x, float y) throws IOException {
setStrokeColor(cs, C_TEAL);
cs.setLineWidth(0.75f);
cs.addRect(x + 0.5f, y + 0.5f, CARD_SIZE - 1, CARD_SIZE - 1);
cs.stroke();
}
private void drawCenteredText(PDPageContentStream cs, PDType1Font font, float size,
String text, Color color, float cardX, float y, float cardW) throws IOException {
float textW = font.getStringWidth(text) / 1000f * size;
cs.setNonStrokingColor(color);
cs.beginText();
cs.setFont(font, size);
cs.newLineAtOffset(cardX + (cardW - textW) / 2f, y);
cs.showText(text);
cs.endText();
}
private void renderWrappedText(PDPageContentStream cs, PDType1Font font, float size,
String text, Color color,
float x, float y, float maxWidth, float lineHeight, int maxLines) throws IOException {
cs.setNonStrokingColor(color);
String[] words = text.split(" ");
StringBuilder line = new StringBuilder();
int linesDrawn = 0;
for (String word : words) {
String candidate = line.isEmpty() ? word : line + " " + word;
if (font.getStringWidth(candidate) / 1000f * size > maxWidth && !line.isEmpty()) {
drawLineRaw(cs, font, size, line.toString(), x, y - linesDrawn * lineHeight, maxWidth);
if (++linesDrawn >= maxLines) return;
line = new StringBuilder(word);
} else {
line = new StringBuilder(candidate);
}
}
if (!line.isEmpty() && linesDrawn < maxLines)
drawLineRaw(cs, font, size, line.toString(), x, y - linesDrawn * lineHeight, maxWidth);
}
private void drawLineRaw(PDPageContentStream cs, PDType1Font font, float size,
String text, float x, float y, float maxWidth) throws IOException {
String display = truncate(text, font, size, maxWidth);
float textW = font.getStringWidth(display) / 1000f * size;
cs.beginText();
cs.setFont(font, size);
cs.newLineAtOffset(x + (maxWidth - textW) / 2f, y);
cs.showText(display);
cs.endText();
}
/** Draws a rounded rectangle path (not stroked/filled yet). r = corner radius. */
private void drawRoundedRect(PDPageContentStream cs,
float x, float y, float w, float h, float r) throws IOException {
float k = r * 0.5523f; // cubic bezier constant for quarter-circle
cs.moveTo(x + r, y);
cs.lineTo(x + w - r, y);
cs.curveTo(x + w - k, y, x + w, y + k, x + w, y + r);
cs.lineTo(x + w, y + h - r);
cs.curveTo(x + w, y + h - k, x + w - k, y + h, x + w - r, y + h);
cs.lineTo(x + r, y + h);
cs.curveTo(x + k, y + h, x, y + h - k, x, y + h - r);
cs.lineTo(x, y + r);
cs.curveTo(x, y + k, x + k, y, x + r, y);
cs.closePath();
}
private void setStrokeColor(PDPageContentStream cs, Color c) throws IOException {
cs.setStrokingColor(c.getRed() / 255f, c.getGreen() / 255f, c.getBlue() / 255f);
}
private float[] cardPosition(int index, boolean mirrored) {
int row = index / COLS;
int col = index % COLS;
if (mirrored) col = COLS - 1 - col;
float x = MARGIN + col * CARD_SIZE;
float y = PAGE_H - MARGIN - (row + 1) * CARD_SIZE;
return new float[]{x, y};
}
private String truncate(String text, PDType1Font font, float size, float maxWidth) throws IOException {
if (font.getStringWidth(text) / 1000f * size <= maxWidth) return text;
while (text.length() > 1) {
text = text.substring(0, text.length() - 1);
if (font.getStringWidth(text + "") / 1000f * size <= maxWidth) return text + "";
}
return text;
}
}

View File

@@ -0,0 +1,92 @@
package de.oaa.libredeck.web.service.pdf;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
@Slf4j
@Component
public class QrCodeGenerator {
// Logo covers 25% of QR width; well within the 30% H-level error-correction budget.
static final float LOGO_RATIO = 0.25f;
private final BufferedImage logoImage;
public QrCodeGenerator() {
BufferedImage logo = null;
try (InputStream is = getClass().getResourceAsStream("/static/images/logo.png")) {
if (is != null) logo = ImageIO.read(is);
} catch (IOException e) {
log.warn("Could not load logo for QR overlay: {}", e.getMessage());
}
logoImage = logo;
}
public BufferedImage generate(String content, int pixelSize) {
try {
BitMatrix matrix = new QRCodeWriter().encode(
content,
BarcodeFormat.QR_CODE,
pixelSize,
pixelSize,
Map.of(
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H,
EncodeHintType.MARGIN, 1
)
);
BufferedImage qr = MatrixToImageWriter.toBufferedImage(matrix);
return logoImage != null ? withLogo(qr) : qr;
} catch (WriterException e) {
throw new IllegalStateException("Failed to generate QR code for: " + content, e);
}
}
/** Aspect ratio (height/width) of the logo, or 0 if no logo is loaded. */
float logoAspect() {
return logoImage == null ? 0f : (float) logoImage.getHeight() / logoImage.getWidth();
}
static int logoPillPad(int pixelSize) {
return Math.max(3, pixelSize / 50);
}
private BufferedImage withLogo(BufferedImage qr) {
int qrW = qr.getWidth();
int qrH = qr.getHeight();
int logoW = Math.round(qrW * LOGO_RATIO);
int logoH = Math.round(logoW * (float) logoImage.getHeight() / logoImage.getWidth());
int lx = (qrW - logoW) / 2;
int ly = (qrH - logoH) / 2;
BufferedImage result = new BufferedImage(qrW, qrH, BufferedImage.TYPE_INT_RGB);
Graphics2D g = result.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(qr, 0, 0, null);
// White backing pill so the logo is readable regardless of QR modules beneath
int pad = Math.max(3, qrW / 50);
g.setColor(Color.WHITE);
g.fillRoundRect(lx - pad, ly - pad, logoW + 2 * pad, logoH + 2 * pad, pad * 2, pad * 2);
g.drawImage(logoImage, lx, ly, logoW, logoH, null);
g.dispose();
return result;
}
}

View File

@@ -0,0 +1,168 @@
package de.oaa.libredeck.web.service.spotify;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.libredeck.web.model.PlaylistSummary;
import de.oaa.libredeck.web.model.Track;
import de.oaa.libredeck.web.service.TrackNormalizer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class SpotifyApiClient {
private static final String API_BASE = "https://api.spotify.com/v1";
private static final long RATE_LIMIT = 100; // ms
private static final ThreadLocal<String> TOKEN_OVERRIDE = new ThreadLocal<>();
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
private final SpotifyTokenStore tokenStore;
private long lastRequest = 0;
/** Call on the request thread before submitting an async task that will use this client. */
public void pinTokenForThread(String token) { TOKEN_OVERRIDE.set(token); }
public void clearThreadToken() { TOKEN_OVERRIDE.remove(); }
public String fetchPlaylistTitle(String playlistId) throws IOException {
JsonNode root = mapper.readTree(get(API_BASE + "/playlists/" + playlistId + "?fields=name"));
return root.path("name").asText("Playlist " + playlistId);
}
public List<Track> fetchTracks(String playlistId) throws IOException {
List<Track> result = new ArrayList<>();
String rawResponse = get(API_BASE + "/playlists/" + playlistId);
log.info("Spotify playlist response: {}", rawResponse);
JsonNode playlist = mapper.readTree(rawResponse);
JsonNode tracksPage = playlist.path("items");
collectItems(tracksPage.path("items"), result);
String url = tracksPage.path("next").isNull() || tracksPage.path("next").isMissingNode()
? null : tracksPage.path("next").asText();
while (url != null) {
try {
JsonNode page = mapper.readTree(get(url));
collectItems(page.path("items"), result);
JsonNode next = page.path("next");
url = next.isMissingNode() || next.isNull() ? null : next.asText();
} catch (IOException e) {
log.warn("Spotify pagination aborted ({}), returning {} tracks", e.getMessage(), result.size());
break;
}
}
return result;
}
public List<PlaylistSummary> fetchUserPlaylists() throws IOException {
List<PlaylistSummary> result = new ArrayList<>();
String url = API_BASE + "/me/playlists?limit=50";
while (url != null) {
JsonNode root = mapper.readTree(get(url));
JsonNode items = root.path("items");
for (JsonNode item : items) {
String id = item.path("id").asText("");
if (id.isEmpty()) continue;
String name = item.path("name").asText("");
int total = item.path("tracks").path("total").asInt(0);
String cover = item.path("images").isArray() && !item.path("images").isEmpty()
? item.path("images").get(0).path("url").asText("") : "";
result.add(new PlaylistSummary(id, name, total, cover));
}
JsonNode next = root.path("next");
url = next.isMissingNode() || next.isNull() ? null : next.asText();
}
return result;
}
public String fetchTrackYear(String trackId) {
try {
JsonNode track = mapper.readTree(get(API_BASE + "/tracks/" + trackId));
String date = track.path("album").path("release_date").asText("");
return date.length() >= 4 ? date.substring(0, 4) : "";
} catch (IOException e) {
log.warn("Could not fetch Spotify track year for {}: {}", trackId, e.getMessage());
return "";
}
}
// ── HTTP helpers ──────────────────────────────────────────────────────────
private synchronized String get(String url) throws IOException {
String token = TOKEN_OVERRIDE.get();
if (token == null) {
if (!tokenStore.hasValidToken()) throw new SpotifyAuthRequiredException();
token = tokenStore.getAccessToken();
}
rateLimitWait();
HttpResponse<String> response = send(url, token);
if (response.statusCode() != 200) {
log.warn("Spotify API {} → {}: {}", url, response.statusCode(), response.body());
throw new IOException("Spotify API error " + response.statusCode() + ": " + response.body());
}
return response.body();
}
private HttpResponse<String> send(String url, String token) throws IOException {
try {
return http.send(
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + token)
.GET().build(),
HttpResponse.BodyHandlers.ofString());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Request interrupted", e);
}
}
private void collectItems(JsonNode items, List<Track> result) {
if (!items.isArray()) return;
for (JsonNode item : items) {
JsonNode track = item.path("item");
if (track.isMissingNode() || track.isNull()) track = item.path("track");
if (track.isMissingNode() || track.isNull()) continue;
String id = track.path("id").asText("");
if (id.isEmpty()) continue;
String rawTitle = track.path("name").asText();
String rawArtist = "";
JsonNode artists = track.path("artists");
if (artists.isArray() && !artists.isEmpty()) {
rawArtist = artists.get(0).path("name").asText("");
}
String streamUrl = track.path("external_urls").path("spotify")
.asText("https://open.spotify.com/track/" + id);
String date = track.path("album").path("release_date").asText("");
String year = date.length() >= 4 ? date.substring(0, 4) : "";
String[] ta = TrackNormalizer.extractFeaturing(TrackNormalizer.cleanTitle(rawTitle), rawArtist);
result.add(new Track(id, ta[0], ta[1], year, streamUrl));
}
}
private void rateLimitWait() {
long wait = RATE_LIMIT - (System.currentTimeMillis() - lastRequest);
if (wait > 0) {
try { Thread.sleep(wait); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
lastRequest = System.currentTimeMillis();
}
}

View File

@@ -0,0 +1,9 @@
package de.oaa.libredeck.web.service.spotify;
public class SpotifyAuthRequiredException extends RuntimeException {
private static final long serialVersionUID = 8637847418458822435L;
public SpotifyAuthRequiredException() {
super("Spotify-Authentifizierung erforderlich");
}
}

Some files were not shown because too many files have changed in this diff Show More