commit 7dd108a58ebb5da910d07919b22949a35b712e47 Author: Mario Date: Sun May 3 21:51:45 2026 +0200 Initialer commit diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..be88c88 --- /dev/null +++ b/.classpath @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02a308e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +build/ +.gradle/ diff --git a/.project b/.project new file mode 100644 index 0000000..36eea49 --- /dev/null +++ b/.project @@ -0,0 +1,28 @@ + + + libredeck-web + Project libredeck-web created by Buildship. + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + org.springframework.ide.eclipse.boot.validation.springbootbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.buildship.core.gradleprojectnature + + diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..e479558 --- /dev/null +++ b/.settings/org.eclipse.buildship.core.prefs @@ -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 diff --git a/.settings/org.springframework.ide.eclipse.prefs b/.settings/org.springframework.ide.eclipse.prefs new file mode 100644 index 0000000..a12794d --- /dev/null +++ b/.settings/org.springframework.ide.eclipse.prefs @@ -0,0 +1,2 @@ +boot.validation.initialized=true +eclipse.preferences.version=1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd71f0e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-temurin:21-jre-jammy +WORKDIR /app +COPY build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/bin/main/application.yml b/bin/main/application.yml new file mode 100644 index 0000000..cad8dca --- /dev/null +++ b/bin/main/application.yml @@ -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} diff --git a/bin/main/de/oaa/libredeck/web/LibredeckWebApplication.class b/bin/main/de/oaa/libredeck/web/LibredeckWebApplication.class new file mode 100644 index 0000000..07d4b9c Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/LibredeckWebApplication.class differ diff --git a/bin/main/de/oaa/libredeck/web/config/AppleMusicConfig.class b/bin/main/de/oaa/libredeck/web/config/AppleMusicConfig.class new file mode 100644 index 0000000..cf01257 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/config/AppleMusicConfig.class differ diff --git a/bin/main/de/oaa/libredeck/web/config/SpotifyConfig.class b/bin/main/de/oaa/libredeck/web/config/SpotifyConfig.class new file mode 100644 index 0000000..1569e31 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/config/SpotifyConfig.class differ diff --git a/bin/main/de/oaa/libredeck/web/config/TidalConfig.class b/bin/main/de/oaa/libredeck/web/config/TidalConfig.class new file mode 100644 index 0000000..6f0658e Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/config/TidalConfig.class differ diff --git a/bin/main/de/oaa/libredeck/web/config/YouTubeConfig.class b/bin/main/de/oaa/libredeck/web/config/YouTubeConfig.class new file mode 100644 index 0000000..128adcc Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/config/YouTubeConfig.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/ApiController.class b/bin/main/de/oaa/libredeck/web/controller/ApiController.class new file mode 100644 index 0000000..03a86ac Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/ApiController.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/AppleMusicController.class b/bin/main/de/oaa/libredeck/web/controller/AppleMusicController.class new file mode 100644 index 0000000..d67932b Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/AppleMusicController.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/GenerateController.class b/bin/main/de/oaa/libredeck/web/controller/GenerateController.class new file mode 100644 index 0000000..197ab79 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/GenerateController.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/GenerateRequest.class b/bin/main/de/oaa/libredeck/web/controller/GenerateRequest.class new file mode 100644 index 0000000..1e2fd25 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/GenerateRequest.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/PlaylistController.class b/bin/main/de/oaa/libredeck/web/controller/PlaylistController.class new file mode 100644 index 0000000..5764dcb Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/PlaylistController.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/SpotifyAuthController.class b/bin/main/de/oaa/libredeck/web/controller/SpotifyAuthController.class new file mode 100644 index 0000000..b1b7ff4 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/SpotifyAuthController.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/SpotifyController.class b/bin/main/de/oaa/libredeck/web/controller/SpotifyController.class new file mode 100644 index 0000000..22ca4f4 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/SpotifyController.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/TidalAuthController.class b/bin/main/de/oaa/libredeck/web/controller/TidalAuthController.class new file mode 100644 index 0000000..95871f5 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/TidalAuthController.class differ diff --git a/bin/main/de/oaa/libredeck/web/controller/TidalController.class b/bin/main/de/oaa/libredeck/web/controller/TidalController.class new file mode 100644 index 0000000..460b4d2 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/controller/TidalController.class differ diff --git a/bin/main/de/oaa/libredeck/web/model/OAuthToken.class b/bin/main/de/oaa/libredeck/web/model/OAuthToken.class new file mode 100644 index 0000000..6add6bd Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/model/OAuthToken.class differ diff --git a/bin/main/de/oaa/libredeck/web/model/PlaylistSummary.class b/bin/main/de/oaa/libredeck/web/model/PlaylistSummary.class new file mode 100644 index 0000000..7b122ff Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/model/PlaylistSummary.class differ diff --git a/bin/main/de/oaa/libredeck/web/model/Track.class b/bin/main/de/oaa/libredeck/web/model/Track.class new file mode 100644 index 0000000..759a3ee Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/model/Track.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/GenerationJob$Phase.class b/bin/main/de/oaa/libredeck/web/service/GenerationJob$Phase.class new file mode 100644 index 0000000..abff92a Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/GenerationJob$Phase.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/GenerationJob.class b/bin/main/de/oaa/libredeck/web/service/GenerationJob.class new file mode 100644 index 0000000..94647c4 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/GenerationJob.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/JobStore.class b/bin/main/de/oaa/libredeck/web/service/JobStore.class new file mode 100644 index 0000000..7c77cd8 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/JobStore.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/MusicBrainzClient.class b/bin/main/de/oaa/libredeck/web/service/MusicBrainzClient.class new file mode 100644 index 0000000..fc7c00a Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/MusicBrainzClient.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/TrackNormalizer.class b/bin/main/de/oaa/libredeck/web/service/TrackNormalizer.class new file mode 100644 index 0000000..fb26159 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/TrackNormalizer.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicApiClient.class b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicApiClient.class new file mode 100644 index 0000000..9aa548e Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicApiClient.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicAuthRequiredException.class b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicAuthRequiredException.class new file mode 100644 index 0000000..0fa2051 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicAuthRequiredException.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicProvider.class b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicProvider.class new file mode 100644 index 0000000..fcb2c35 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicProvider.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenGenerator.class b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenGenerator.class new file mode 100644 index 0000000..5c4a3af Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenGenerator.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenStore.class b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenStore.class new file mode 100644 index 0000000..431d3eb Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenStore.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/deezer/DeezerApiClient.class b/bin/main/de/oaa/libredeck/web/service/deezer/DeezerApiClient.class new file mode 100644 index 0000000..d9430b5 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/deezer/DeezerApiClient.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/deezer/DeezerProvider.class b/bin/main/de/oaa/libredeck/web/service/deezer/DeezerProvider.class new file mode 100644 index 0000000..256bde5 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/deezer/DeezerProvider.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/pdf/PdfCardGenerator.class b/bin/main/de/oaa/libredeck/web/service/pdf/PdfCardGenerator.class new file mode 100644 index 0000000..f2f831f Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/pdf/PdfCardGenerator.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/pdf/QrCodeGenerator.class b/bin/main/de/oaa/libredeck/web/service/pdf/QrCodeGenerator.class new file mode 100644 index 0000000..ab4cf99 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/pdf/QrCodeGenerator.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyApiClient.class b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyApiClient.class new file mode 100644 index 0000000..2d970a0 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyApiClient.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyAuthRequiredException.class b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyAuthRequiredException.class new file mode 100644 index 0000000..3ce3ffc Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyAuthRequiredException.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyProvider.class b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyProvider.class new file mode 100644 index 0000000..6c4df4c Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyProvider.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyTokenRefresher.class b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyTokenRefresher.class new file mode 100644 index 0000000..5c9c36c Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyTokenRefresher.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyTokenStore.class b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyTokenStore.class new file mode 100644 index 0000000..2ed7925 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/spotify/SpotifyTokenStore.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/streaming/StreamingProvider.class b/bin/main/de/oaa/libredeck/web/service/streaming/StreamingProvider.class new file mode 100644 index 0000000..6573a95 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/streaming/StreamingProvider.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/streaming/StreamingProviderRegistry.class b/bin/main/de/oaa/libredeck/web/service/streaming/StreamingProviderRegistry.class new file mode 100644 index 0000000..6eb92ee Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/streaming/StreamingProviderRegistry.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/tidal/TidalApiClient.class b/bin/main/de/oaa/libredeck/web/service/tidal/TidalApiClient.class new file mode 100644 index 0000000..435e6a3 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/tidal/TidalApiClient.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/tidal/TidalAuthRequiredException.class b/bin/main/de/oaa/libredeck/web/service/tidal/TidalAuthRequiredException.class new file mode 100644 index 0000000..4b40710 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/tidal/TidalAuthRequiredException.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/tidal/TidalProvider.class b/bin/main/de/oaa/libredeck/web/service/tidal/TidalProvider.class new file mode 100644 index 0000000..3d7b4dc Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/tidal/TidalProvider.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/tidal/TidalTokenStore.class b/bin/main/de/oaa/libredeck/web/service/tidal/TidalTokenStore.class new file mode 100644 index 0000000..3b5777b Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/tidal/TidalTokenStore.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/youtube/YouTubeApiClient.class b/bin/main/de/oaa/libredeck/web/service/youtube/YouTubeApiClient.class new file mode 100644 index 0000000..6bc679e Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/youtube/YouTubeApiClient.class differ diff --git a/bin/main/de/oaa/libredeck/web/service/youtube/YouTubeProvider.class b/bin/main/de/oaa/libredeck/web/service/youtube/YouTubeProvider.class new file mode 100644 index 0000000..c517c58 Binary files /dev/null and b/bin/main/de/oaa/libredeck/web/service/youtube/YouTubeProvider.class differ diff --git a/bin/main/static/css/style.css b/bin/main/static/css/style.css new file mode 100644 index 0000000..9bb1892 --- /dev/null +++ b/bin/main/static/css/style.css @@ -0,0 +1,1039 @@ +/* ── Reset & tokens ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + /* Brand colours extracted from the LibreDeck logo */ + --teal: #1a9aaa; + --teal-h: #22b5c8; + --teal-dark: #136e7a; + --green: #46c14a; + --green-h: #55d659; + --grad: linear-gradient(135deg, #1a9aaa 0%, #46c14a 100%); + --grad-glow: linear-gradient(135deg, rgba(26,154,170,.35) 0%, rgba(70,193,74,.25) 100%); + + /* Backgrounds – dark with subtle teal tint */ + --bg: #080d0e; + --bg-2: #0c1415; + --surface: #101c1e; + --surface-2: #162224; + --border: #1c3236; + --border-h: #2a4e54; + + /* Text */ + --text: #dff0f2; + --text-muted: #6e9ea6; + --text-dim: #3a6068; + + --radius: 14px; + --radius-sm: 8px; +} + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } +strong { color: var(--text); } + +/* ── Layout ── */ +.section-inner { + max-width: 1000px; + margin: 0 auto; + padding: 0 1.5rem; +} +.section-title { + font-size: clamp(1.5rem, 3vw, 2rem); + font-weight: 800; + letter-spacing: -.5px; + margin-bottom: .5rem; +} +.section-sub { + color: var(--text-muted); + font-size: 1.05rem; + margin-bottom: 2.5rem; +} +.accent { color: var(--teal); } + +/* ── Navbar ── */ +.navbar { + position: sticky; + top: 0; + z-index: 100; + background: rgba(8,13,14,.88); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--border); +} +.nav-inner { + max-width: 1000px; + margin: 0 auto; + padding: .7rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.nav-logo { + height: 40px; + width: auto; + display: block; +} + +/* ── Buttons ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; + background: var(--grad); + color: #fff; + border: none; + border-radius: var(--radius-sm); + padding: .6rem 1.25rem; + font-size: .95rem; + font-weight: 700; + cursor: pointer; + transition: filter .15s, box-shadow .15s; + white-space: nowrap; + line-height: 1; +} +.btn:hover { + filter: brightness(1.12); + box-shadow: 0 0 22px rgba(26,154,170,.4), 0 0 22px rgba(70,193,74,.2); +} +.btn-sm { font-size: .82rem; padding: .45rem .9rem; border-radius: 6px; } +.btn-lg { font-size: 1rem; padding: .8rem 1.9rem; border-radius: 10px; } + +/* ── Hero ── */ +.hero { + padding: 6rem 0 5rem; + background: + radial-gradient(ellipse 55% 45% at 65% 0%, rgba(26,154,170,.13) 0%, transparent 70%), + radial-gradient(ellipse 35% 30% at 80% 80%, rgba(70,193,74,.08) 0%, transparent 70%); +} +.hero-inner { + max-width: 1000px; + margin: 0 auto; + padding: 0 1.5rem; + display: grid; + grid-template-columns: 1fr auto; + gap: 4rem; + align-items: center; +} +.hero-brand { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.hero-brand-logo { + width: 260px; + height: auto; + filter: drop-shadow(0 0 32px rgba(26,154,170,.5)) drop-shadow(0 0 64px rgba(70,193,74,.25)); + animation: logoPulse 5s ease-in-out infinite; +} +@keyframes logoPulse { + 0%, 100% { + filter: drop-shadow(0 0 32px rgba(26,154,170,.5)) drop-shadow(0 0 64px rgba(70,193,74,.25)); + } + 50% { + filter: drop-shadow(0 0 48px rgba(26,154,170,.75)) drop-shadow(0 0 96px rgba(70,193,74,.4)); + } +} +.hero-eyebrow { + font-size: .78rem; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--teal); + margin-bottom: 1rem; +} +.hero-text h1 { + font-size: clamp(2rem, 5vw, 3.2rem); + font-weight: 900; + letter-spacing: -1.5px; + line-height: 1.15; + margin-bottom: 1.25rem; +} +.hero-sub { + color: var(--text-muted); + font-size: 1.1rem; + max-width: 480px; + margin-bottom: 2rem; + line-height: 1.7; +} + +/* ── Card Mockups ── */ +.hero-cards { + display: flex; + gap: 16px; + align-items: flex-start; + flex-shrink: 0; +} +.card-mockup { + width: 120px; + height: 120px; + border-radius: 10px; + border: 1px solid rgba(26,154,170,.3); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + position: relative; + overflow: hidden; + background: #fff; + color: #111; + box-shadow: 0 8px 32px rgba(0,0,0,.5), 0 0 0 1px rgba(26,154,170,.15); +} +.card-mockup.small { width: 90px; height: 90px; border-radius: 7px; } +.card-front { margin-top: 20px; } + +.mock-year { font-size: 22px; font-weight: 900; color: #1a7a8a; letter-spacing: -1px; line-height: 1; } +.mock-divider { width: 70%; height: 1px; background: #c5e6ea; margin: 2px 0; } +.mock-artist { font-size: 8.5px; color: #555; text-align: center; padding: 0 6px; line-height: 1.2; } +.mock-title { font-size: 8.5px; font-weight: 700; color: #1a7a8a; text-align: center; padding: 0 6px; line-height: 1.2; } +.mock-brand { position: absolute; bottom: 4px; font-size: 6px; color: #aac; letter-spacing: .05em; } + +.card-mockup.small .mock-year { font-size: 16px; } +.card-mockup.small .mock-artist, +.card-mockup.small .mock-title { font-size: 6.5px; } + +.mock-qr { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; } +.qr-grid { + width: 72px; height: 72px; + background-image: + linear-gradient(#1a7a8a 0, #1a7a8a 100%), + linear-gradient(#1a7a8a 0, #1a7a8a 100%), + linear-gradient(#1a7a8a 0, #1a7a8a 100%), + repeating-linear-gradient(90deg, transparent 0, transparent 6px, #1a7a8a 6px, #1a7a8a 8px), + repeating-linear-gradient(0deg, transparent 0, transparent 6px, #1a7a8a 6px, #1a7a8a 8px); + background-size: 18px 18px, 18px 18px, 18px 18px, 100% 100%, 100% 100%; + background-position: 0 0, calc(100% - 0px) 0, 0 calc(100% - 0px), 0 0, 0 0; + background-repeat: no-repeat, no-repeat, no-repeat, repeat, repeat; + opacity: .8; + border: 1px solid #c5e6ea; + border-radius: 2px; +} + +/* ── How it works ── */ +.how-section { + padding: 5rem 0; + background: var(--bg-2); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.steps-viewport { + display: grid; + overflow: hidden; +} +.how-steps { + grid-column: 1; + grid-row: 1; + transition: transform .35s ease; +} +.steps { + display: flex; + align-items: stretch; + gap: 1rem; + margin-top: 2.5rem; + flex-wrap: wrap; +} +.step { + flex: 1; + min-width: 200px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.75rem 1.5rem; + transition: border-color .2s; +} +.step:hover { border-color: var(--border-h); } + +.step-icon { + width: 44px; height: 44px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(26,154,170,.15), rgba(70,193,74,.1)); + border: 1px solid rgba(26,154,170,.25); + display: flex; align-items: center; justify-content: center; + margin-bottom: 1.25rem; + color: var(--teal); +} +.step-icon svg { width: 22px; height: 22px; } +.step h3 { font-size: 1rem; font-weight: 700; margin-bottom: .5rem; color: var(--text); } +.step p { font-size: .9rem; color: var(--text-muted); line-height: 1.55; margin: 0; } + +.step-arrow { + font-size: 1.5rem; + color: var(--text-dim); + align-self: center; + flex-shrink: 0; + margin-top: -1rem; +} + +/* ── Card detail ── */ +.detail-section { padding: 5rem 0; } +.detail-inner { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + align-items: center; +} +.detail-text h2 { + font-size: clamp(1.4rem, 2.5vw, 1.9rem); + font-weight: 800; + letter-spacing: -.5px; + margin-bottom: 1.75rem; +} +.detail-list { list-style: none; display: flex; flex-direction: column; gap: 1.25rem; } +.detail-list li { display: flex; gap: 1rem; align-items: flex-start; } +.detail-icon { + flex-shrink: 0; + width: 34px; height: 34px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); + display: flex; align-items: center; justify-content: center; + font-size: .7rem; font-weight: 800; + color: var(--teal); +} +.detail-list strong { display: block; font-size: .95rem; margin-bottom: .2rem; } +.detail-list p { font-size: .875rem; color: var(--text-muted); margin: 0; line-height: 1.5; } + +/* ── Card grid: 2 × 3, each card slightly tilted ── */ +.preview-stack { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: 10px; + padding: 8px; +} +.preview-stack .card-mockup:nth-child(1) { transform: rotate(-4deg) translate(0, 3px); } +.preview-stack .card-mockup:nth-child(2) { transform: rotate( 3deg) translate(0, 6px); } +.preview-stack .card-mockup:nth-child(3) { transform: rotate( 5deg) translate(2px, -4px); } +.preview-stack .card-mockup:nth-child(4) { transform: rotate(-3deg) translate(0, -2px); } +.preview-stack .card-mockup:nth-child(5) { transform: rotate(-2deg) translate(-3px, 5px); } +.preview-stack .card-mockup:nth-child(6) { transform: rotate( 4deg) translate(1px, -3px); } + +/* ── Generator ── */ +.generator-section { + padding: 5rem 0 6rem; + background: var(--bg-2); + border-top: 1px solid var(--border); +} +.generator-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2.5rem; + box-shadow: 0 0 60px rgba(26,154,170,.06); +} +.field-label { + display: block; + font-size: .82rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .07em; + margin-bottom: .6rem; +} +.input-row { display: flex; gap: .75rem; flex-wrap: wrap; } +.url-input { + flex: 1; + min-width: 200px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 1rem; + padding: .7rem 1rem; + outline: none; + transition: border-color .15s, box-shadow .15s; +} +.url-input::placeholder { color: var(--text-dim); } +.url-input:focus { + border-color: var(--teal); + box-shadow: 0 0 0 3px rgba(26,154,170,.2); +} + +/* ── Hint details ── */ +.hint-details { + margin-top: 1.5rem; + border-top: 1px solid var(--border); + padding-top: 1.25rem; +} +.hint-details summary { + font-size: .88rem; + color: var(--text-muted); + cursor: pointer; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: .4rem; +} +.hint-details summary::before { + content: '▶'; + font-size: .65rem; + color: var(--teal); + transition: transform .15s; +} +.hint-details[open] summary::before { transform: rotate(90deg); } +.hint-steps { + color: var(--text-muted); + font-size: .88rem; + padding-left: 1.5rem; + margin: .75rem 0; +} +.hint-steps li { margin-bottom: .3rem; } +.hint-example { font-size: .82rem; color: var(--text-muted); margin: 0; } +.hint-example code { + background: var(--bg); + border: 1px solid var(--border); + padding: .15rem .5rem; + border-radius: 5px; + font-size: .8rem; +} + +/* ── Review section ── */ +.review-section { + margin-top: 2rem; + border-top: 1px solid var(--border); + padding-top: 1.75rem; +} +.review-heading { + font-size: 1.05rem; + font-weight: 700; + color: var(--text); + margin-bottom: .5rem; +} +.review-sub { + font-size: .88rem; + color: var(--text-muted); + margin-bottom: 1.25rem; + line-height: 1.55; +} +.review-table { + display: flex; + flex-direction: column; + gap: .5rem; + margin-bottom: 1.5rem; +} +.review-row { + display: grid; + grid-template-columns: 1fr 1.5fr 80px auto; + gap: .5rem; + align-items: center; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: .5rem .75rem; +} +/* YouTube rows have an extra swap button between artist and title columns */ +.review-row:has(.btn-swap) { + grid-template-columns: 1fr auto 1.5fr 80px auto; +} +.btn-swap { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + padding: .2rem .3rem; + line-height: 1; + border-radius: 4px; + transition: color .15s; +} +.btn-swap:hover { color: var(--accent); } +.review-artist-input, +.review-title-input { + font-size: .85rem; + padding: .3rem .5rem; + min-width: 0; +} +.review-row .year-input { + min-width: 0; + text-align: center; + padding: .45rem .5rem; + font-size: .9rem; +} +.btn-check { + background: transparent; + border: 1px solid var(--border-h); + color: var(--text-muted); + border-radius: 6px; + padding: .35rem .7rem; + font-size: .8rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: border-color .15s, color .15s; + line-height: 1; +} +.btn-check:hover:not(:disabled) { border-color: var(--teal); color: var(--teal); } +.btn-check:disabled { opacity: .45; cursor: default; } + +.year-suggestion { + display: flex; + align-items: center; + gap: .35rem; + font-size: .8rem; + color: var(--text-muted); + white-space: nowrap; +} +.suggestion-year { color: var(--teal); font-weight: 700; } +.suggestion-accept, .suggestion-reject { + background: transparent; + border: 1px solid var(--border-h); + border-radius: 4px; + padding: .2rem .45rem; + font-size: .75rem; + cursor: pointer; + line-height: 1; + transition: background .12s, color .12s; +} +.suggestion-accept { color: var(--green); } +.suggestion-accept:hover { background: rgba(70,193,74,.15); } +.suggestion-reject { color: var(--text-muted); } +.suggestion-reject:hover { background: rgba(255,255,255,.06); } + +.check-cell { + display: flex; + align-items: center; + gap: .5rem; +} + +.review-row--missing { + border-color: rgba(180,120,30,.4); + background: rgba(180,120,30,.06); +} +.year-input--missing { + border-color: rgba(180,120,30,.5) !important; +} +.review-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: .75rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} +.btn-play { + background: linear-gradient(135deg, var(--teal-dark) 0%, var(--teal) 100%); +} + +/* ── Play dialog ── */ +.play-dialog-inner { + position: relative; + background: var(--surface); + border: 1px solid var(--border-h); + border-radius: var(--radius); + padding: 2.5rem 2rem 2rem; + width: min(480px, 96vw); + display: flex; + flex-direction: column; + align-items: center; + gap: .5rem; +} +.play-close { + position: absolute; + top: .9rem; + right: 1rem; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1.2rem; + cursor: pointer; + line-height: 1; + padding: .25rem .5rem; + border-radius: 6px; + transition: color .15s; +} +.play-close:hover { color: var(--text); } +.play-remaining { + font-size: .82rem; + color: var(--text-dim); + margin-bottom: .25rem; + align-self: flex-end; +} + +/* card flip */ +#playCardWrap { + perspective: 900px; +} +.play-card { + width: min(260px, 72vw); + height: min(260px, 72vw); + position: relative; + transform-style: preserve-3d; + transition: transform .55s cubic-bezier(.4,0,.2,1); +} +.play-card.flipped { transform: rotateY(180deg); } +.play-face { + position: absolute; + inset: 0; + backface-visibility: hidden; + background: #ffffff; + border: 1.5px solid #1a9aaa; + border-radius: 4px; + overflow: hidden; +} +.play-front { + display: flex; + align-items: center; + justify-content: center; +} +.play-back { transform: rotateY(180deg); } +.play-qr { + width: 200px; + height: 200px; + flex-shrink: 0; +} +.play-qr img { + display: block; + width: 100%; + height: 100%; +} +/* Back face — absolute layout matching PDF proportions */ +.play-back-year { + position: absolute; + left: 0; right: 0; + top: 22%; + text-align: center; + font-size: 1.75rem; + font-weight: 800; + color: #136e7a; + margin: 0; + letter-spacing: -.5px; +} +.play-back-divider { + position: absolute; + top: 39%; + left: 8%; right: 8%; + border: none; + border-top: 1px solid #c5e6ea; + margin: 0; +} +.play-back-artist { + position: absolute; + top: 43%; + left: 6%; right: 6%; + text-align: center; + font-size: .82rem; + color: #555555; + margin: 0; + line-height: 1.35; +} +.play-back-title { + position: absolute; + top: 62%; + left: 6%; right: 6%; + text-align: center; + font-size: .9rem; + font-weight: 700; + color: #1a9aaa; + margin: 0; + line-height: 1.35; +} +.play-brand { + position: absolute; + bottom: 5%; + left: 0; right: 0; + text-align: center; + font-size: .55rem; + color: #aaaacc; + letter-spacing: .06em; + text-transform: uppercase; +} +.play-actions { + display: flex; + gap: .75rem; + margin-top: 1.25rem; + justify-content: center; +} +.bulk-progress { + display: flex; + align-items: center; + gap: .75rem; + margin-bottom: .25rem; +} +.bulk-progress-label { + font-size: .85rem; + color: var(--text-muted); + white-space: nowrap; +} +.review-row--updated { + border-color: rgba(26,154,170,.45); + background: rgba(26,154,170,.06); +} +.btn-outline { + background: transparent; + border: 1px solid var(--border-h); + color: var(--text-muted); + border-radius: var(--radius-sm); + padding: .6rem 1.25rem; + font-size: .95rem; + font-weight: 700; + cursor: pointer; + transition: border-color .15s, color .15s; + white-space: nowrap; + line-height: 1; +} +.btn-outline:hover { border-color: var(--teal); color: var(--teal); } + +/* ── Error ── */ +.error-msg { + background: rgba(180,30,30,.12); + border: 1px solid rgba(180,30,30,.35); + color: #ffaaaa; + border-radius: var(--radius-sm); + padding: .8rem 1rem; + margin-top: 1rem; + font-size: .9rem; +} + +/* ── Loading overlay ── */ +#loading-overlay { + position: fixed; + inset: 0; + background: rgba(8,13,14,.92); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.25rem; + z-index: 999; +} +#loading-overlay p { color: var(--text); font-size: 1.1rem; margin: 0; } +.loading-sub { color: var(--text-muted) !important; font-size: .9rem !important; } + +.btn-cancel { + background: transparent; + border: 1px solid var(--border-h); + color: var(--text-muted); + border-radius: var(--radius-sm); + padding: .45rem 1.1rem; + font-size: .85rem; + font-weight: 600; + cursor: pointer; + transition: border-color .15s, color .15s; + margin-top: .25rem; +} +.btn-cancel:hover { border-color: var(--text-muted); color: var(--text); } + +.spotify-auth-box { + margin-top: 1.25rem; + padding: 1rem 1.25rem; + border: 1px solid #1db954; + border-radius: var(--radius-sm); + background: rgba(29,185,84,.07); + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} +.spotify-auth-box p { margin: 0; font-size: .9rem; color: var(--text-muted); flex: 1; } +.spotify-btn { background: #1db954; } +.spotify-btn:hover { filter: brightness(1.1); } +.tidal-btn { background: #000; border: 1px solid #444; } +.tidal-btn:hover { background: #111; } + +#progressWrap { + display: flex; + flex-direction: column; + align-items: center; + gap: .6rem; + width: 260px; +} +.progress-bar-bg { + width: 100%; + height: 6px; + background: var(--border); + border-radius: 99px; + overflow: hidden; +} +.progress-bar-fill { + height: 100%; + width: 0%; + background: var(--grad); + border-radius: 99px; + transition: width .35s ease; +} + +.spinner { + width: 44px; height: 44px; + border: 3px solid var(--border); + border-top-color: var(--teal); + border-right-color: var(--green); + border-radius: 50%; + animation: spin .75s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Modal dialog ── */ +#deezerNotice, +#bulkCheckDialog { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} +.dialog-backdrop { + position: absolute; + inset: 0; + background: rgba(8,13,14,.75); + backdrop-filter: blur(4px); +} +.dialog-box { + position: relative; + background: var(--surface); + border: 1px solid var(--border-h); + border-radius: var(--radius); + padding: 2rem 2rem 1.75rem; + max-width: 460px; + width: calc(100% - 2rem); + box-shadow: 0 24px 64px rgba(0,0,0,.6), 0 0 0 1px rgba(26,154,170,.15); +} +.dialog-title { + font-size: 1.05rem; + font-weight: 700; + color: var(--text); + margin-bottom: 1rem; +} +.dialog-body { + font-size: .9rem; + color: var(--text-muted); + line-height: 1.65; + margin-bottom: 1.5rem; +} +.dialog-body strong { color: var(--text); } +.dialog-actions { display: flex; justify-content: flex-end; } + +/* ── Footer ── */ +.site-footer { + background: var(--bg); + border-top: 1px solid var(--border); + padding: 2.5rem 0; +} +.footer-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.footer-logo-img { height: 32px; width: auto; opacity: .8; } +.site-footer p { color: var(--text-dim); font-size: .82rem; margin: 0; } + +/* ── Service Topbar ── */ +.service-topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: var(--bg); + border-bottom: 1px solid var(--border); + padding: .825rem 0; +} +body { padding-top: 4rem; } +.topbar-inner { + display: flex; + align-items: center; + justify-content: center; + gap: 1.875rem; +} +.topbar-service { + display: flex; + align-items: center; + gap: .75rem; + min-width: 165px; + justify-content: center; +} +.topbar-icon { + width: 30px; + height: 30px; + border-radius: 6px; + object-fit: contain; +} +.topbar-service-name { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); +} +.topbar-nav { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font-size: 1.5rem; + padding: .45rem 1.125rem; + transition: background .15s, border-color .15s; + line-height: 1; +} +.topbar-nav:hover { background: var(--surface); border-color: var(--teal); } + +.service-hint { + font-size: .9rem; + color: var(--text-muted); + margin-bottom: 1rem; + text-align: center; +} +.gen-viewport { + display: grid; + overflow: hidden; +} +.service-panel { + grid-column: 1; + grid-row: 1; + transition: transform .35s ease; +} +#panelSpotify, #panelTidal, #panelApple { text-align: center; } +.spotify-url-row { + display: flex; + gap: .5rem; + margin-top: 1rem; + text-align: left; +} + +/* ── Playlist Picker ── */ +.picker-label { font-size: .85rem; color: var(--text-muted); margin: 1rem 0 0; } +.picker-header { + display: flex; + align-items: center; + justify-content: space-between; + margin: 1rem 0 .25rem; +} +.btn-refresh { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + padding: .15rem .45rem; + line-height: 1; + transition: color .15s, border-color .15s; +} +.btn-refresh:hover { color: var(--text); border-color: var(--teal); } + +/* ── Carousel ── */ +.playlist-carousel { + display: flex; + align-items: stretch; + gap: 6px; + margin-top: .5rem; +} +.carousel-viewport { + container-type: inline-size; /* enables cqw on children */ + flex: 1; + overflow: hidden; + min-width: 0; +} +.carousel-track { + display: flex; + flex-wrap: nowrap; + gap: 10px; + transition: transform .35s cubic-bezier(.25,.46,.45,.94); + will-change: transform; +} +/* 5 cards per row – width derived from the viewport container, not the track */ +.carousel-track .playlist-card { + flex-shrink: 0; + width: calc((100cqw - 4 * 10px) / 5); +} +@container (max-width: 500px) { + .carousel-track .playlist-card { + width: calc((100cqw - 2 * 10px) / 3); + } +} +.carousel-btn { + flex-shrink: 0; + width: 34px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font-size: 1.3rem; + line-height: 1; + transition: background .15s, border-color .15s, opacity .15s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.carousel-btn:hover:not(:disabled) { background: var(--surface); border-color: var(--teal); } +.carousel-btn:disabled { opacity: .22; cursor: default; } + +.playlist-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: .55rem; + cursor: pointer; + transition: border-color .15s, background .15s; + display: flex; + flex-direction: column; + gap: .35rem; + flex-shrink: 0; + min-width: 0; +} +.playlist-card:hover { border-color: var(--teal); background: var(--surface); } +.playlist-card img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; background: var(--surface-2); display: block; } +.playlist-card-nocover { + width: 100%; + aspect-ratio: 1; + border-radius: 4px; + background: var(--surface-2); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.6rem; +} +.playlist-card-name { + font-size: .78rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* constrain URL input rows and single-action panels to a readable width */ +.spotify-url-row { max-width: 600px; margin-left: auto; margin-right: auto; } +#panelDeezer .input-row, +#panelDeezer .hint-details { max-width: 600px; margin-left: auto; margin-right: auto; } +#panelYoutube .input-row, +#panelYoutube .hint-details { max-width: 600px; margin-left: auto; margin-right: auto; } +.youtube-notice { + font-size: .85rem; + color: var(--text-muted); + margin: .75rem auto 0; + max-width: 600px; + text-align: center; +} + +/* ── Responsive ── */ +@media (max-width: 760px) { + .hero-inner { grid-template-columns: 1fr; gap: 2.5rem; } + .hero-brand { justify-content: center; } + .hero-brand-logo { width: 200px; } + .hero-cards { justify-content: center; } + .detail-inner { grid-template-columns: 1fr; gap: 3rem; } + .steps { flex-direction: column; } + .step-arrow { display: none; } + .hero { padding: 4rem 0 3rem; } + .generator-card { padding: 1.75rem 1rem; } + .review-row { grid-template-columns: 1fr 70px auto; } + .review-artist-input { display: none; } +} diff --git a/bin/main/static/images/favicon.png b/bin/main/static/images/favicon.png new file mode 100644 index 0000000..554126f Binary files /dev/null and b/bin/main/static/images/favicon.png differ diff --git a/bin/main/static/images/logo.png b/bin/main/static/images/logo.png new file mode 100644 index 0000000..1d82b0e Binary files /dev/null and b/bin/main/static/images/logo.png differ diff --git a/bin/main/templates/index.html b/bin/main/templates/index.html new file mode 100644 index 0000000..3851c85 --- /dev/null +++ b/bin/main/templates/index.html @@ -0,0 +1,1457 @@ + + + + + + LibreDeck – Spielkarten aus deinen Playlists + + + + + + + +
+
+
+

Kostenlos · Kein Account nötig

+

Spielkarten aus deinen
Lieblings‑Playlists

+

+ Füge einen Playlist-Link ein und lade ein druckfertiges PDF herunter. + Jede Karte hat vorne einen QR-Code zum Abspielen und hinten + Künstler, Titel und Erscheinungsjahr. +

+ Jetzt Karten erstellen +
+ + +
+
+ + +
+
+ +
+ + Spotify +
+ +
+
+ + +
+
+

So funktioniert es

+ +
+ +
+
+
+ + + +
+

Mit Spotify verbinden

+

Klicke auf „Mit Spotify verbinden" und melde dich einmalig pro Sitzung an.

+
+ +
+
+ + + +
+

Playlist auswählen

+

Deine Playlists erscheinen direkt zur Auswahl – einfach anklicken und PDF wird erstellt.

+
+ +
+
+ + + +
+

Ausdrucken & spielen

+

PDF doppelseitig drucken, Karten ausschneiden – fertig.

+
+
+ + + + + + + + + + + + +
+
+
+ + +
+
+
+

Was drauf steht

+
    +
  • + V +
    + Vorderseite +

    QR-Code, der direkt zum Track auf Deezer führt – einfach scannen und Musik läuft.

    +
    +
  • +
  • + R +
    + Rückseite +

    Erscheinungsjahr, Künstlername und Titel – alles was du zum Raten und Einordnen brauchst.

    +
    +
  • +
  • + +
    + Druckfertig +

    35 Karten à 40 × 40 mm auf einer A4-Seite

    +
    +
  • +
+
+ +
+
+ + +
+
+

PDF erstellen

+

Wähle deinen Streaming-Dienst und starte direkt.

+ +
+ +
+ + +
+
+

Verbinde dich einmalig mit Spotify – dann kannst du deine Playlists direkt auswählen.

+ Mit Spotify verbinden +
+ +
+ + + + + + + + + + + + + +
+ + +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + diff --git a/bin/main/templates/playlists.html b/bin/main/templates/playlists.html new file mode 100644 index 0000000..a4f31d0 --- /dev/null +++ b/bin/main/templates/playlists.html @@ -0,0 +1,62 @@ + + + + + + Playlists – libredeck + + + +
+
+

libredeck

+
+ + Abmelden +
+
+ +
+

Deine Playlists

+

Wähle eine Playlist aus, um ein druckfertiges PDF zu generieren.

+ +
+
+ Cover +
+ +
+

Playlist Name

+

+
+ + + PDF generieren + +
+
+ +
+

Keine Playlists gefunden.

+
+
+
+ + + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..1738d32 --- /dev/null +++ b/build.gradle @@ -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/'] = 'UTF-8' + } + } + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.compilerArgs << '-parameters' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..04ec71a --- /dev/null +++ b/deploy.sh @@ -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 ---" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe68299 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7f93135 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/memory/feedback_secrets.md b/memory/feedback_secrets.md new file mode 100644 index 0000000..d735977 --- /dev/null +++ b/memory/feedback_secrets.md @@ -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. diff --git a/nextsteps.txt b/nextsteps.txt new file mode 100644 index 0000000..c3fbd5e --- /dev/null +++ b/nextsteps.txt @@ -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". + diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..5b239c9 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'libredeck-web' diff --git a/src/main/java/de/oaa/libredeck/web/LibredeckWebApplication.java b/src/main/java/de/oaa/libredeck/web/LibredeckWebApplication.java new file mode 100644 index 0000000..c6b38da --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/LibredeckWebApplication.java @@ -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); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/config/AppleMusicConfig.java b/src/main/java/de/oaa/libredeck/web/config/AppleMusicConfig.java new file mode 100644 index 0000000..4e2926c --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/config/AppleMusicConfig.java @@ -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 +} diff --git a/src/main/java/de/oaa/libredeck/web/config/DeezerConfig.java b/src/main/java/de/oaa/libredeck/web/config/DeezerConfig.java new file mode 100644 index 0000000..b650db8 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/config/DeezerConfig.java @@ -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; +// } diff --git a/src/main/java/de/oaa/libredeck/web/config/SpotifyConfig.java b/src/main/java/de/oaa/libredeck/web/config/SpotifyConfig.java new file mode 100644 index 0000000..71486f9 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/config/SpotifyConfig.java @@ -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"; +} diff --git a/src/main/java/de/oaa/libredeck/web/config/TidalConfig.java b/src/main/java/de/oaa/libredeck/web/config/TidalConfig.java new file mode 100644 index 0000000..6567f11 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/config/TidalConfig.java @@ -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"; +} diff --git a/src/main/java/de/oaa/libredeck/web/config/YouTubeConfig.java b/src/main/java/de/oaa/libredeck/web/config/YouTubeConfig.java new file mode 100644 index 0000000..a7c618b --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/config/YouTubeConfig.java @@ -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 = ""; +} diff --git a/src/main/java/de/oaa/libredeck/web/controller/ApiController.java b/src/main/java/de/oaa/libredeck/web/controller/ApiController.java new file mode 100644 index 0000000..3863aca --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/ApiController.java @@ -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 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 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 playlists(@PathVariable String provider, + // @RequestHeader(HttpHeaders.AUTHORIZATION) String auth) throws IOException { + // return registry.get(provider).fetchPlaylists(extractToken(auth)); + // } + // + // @GetMapping("/{provider}/generate") + // public ResponseEntity generateWithAuth(@PathVariable String provider, + // @RequestParam String playlistId, + // @RequestParam String playlistTitle, + // @RequestHeader(HttpHeaders.AUTHORIZATION) String auth) throws IOException { + // String token = extractToken(auth); + // List 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); + // } +} diff --git a/src/main/java/de/oaa/libredeck/web/controller/AppleMusicController.java b/src/main/java/de/oaa/libredeck/web/controller/AppleMusicController.java new file mode 100644 index 0000000..3ea5151 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/AppleMusicController.java @@ -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 configured() { + return Map.of("configured", tokenGen.isConfigured()); + } + + @GetMapping("/connected") + public Map 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 setToken(@RequestBody Map body) { + String token = body.getOrDefault("musicUserToken", ""); + if (!token.isBlank()) tokenStore.setMusicUserToken(token); + return Map.of("status", "ok"); + } + + @PostMapping("/disconnect") + public Map 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 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())); + } + } +} diff --git a/src/main/java/de/oaa/libredeck/web/controller/AuthController.java b/src/main/java/de/oaa/libredeck/web/controller/AuthController.java new file mode 100644 index 0000000..8e292f3 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/AuthController.java @@ -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:/"; +// } +// } diff --git a/src/main/java/de/oaa/libredeck/web/controller/GenerateController.java b/src/main/java/de/oaa/libredeck/web/controller/GenerateController.java new file mode 100644 index 0000000..48e9b9e --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/GenerateController.java @@ -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 lookup(@RequestParam(name = "playlistUrl") String playlistUrl) { + // Check auth before spawning the async job + Optional 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 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 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 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 yearOverrides = req.years() != null ? req.years() : Map.of(); + Map artistOverrides = req.artists() != null ? req.artists() : Map.of(); + Map titleOverrides = req.titles() != null ? req.titles() : Map.of(); + + List 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 status(@RequestParam(name = "jobId") String jobId) { + GenerationJob job = jobStore.get(jobId) + .orElseThrow(() -> new IllegalArgumentException("Job nicht gefunden: " + jobId)); + + Map 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> 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 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 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 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; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/controller/GenerateRequest.java b/src/main/java/de/oaa/libredeck/web/controller/GenerateRequest.java new file mode 100644 index 0000000..8f1a74a --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/GenerateRequest.java @@ -0,0 +1,10 @@ +package de.oaa.libredeck.web.controller; + +import java.util.Map; + +public record GenerateRequest( + String jobId, + Map years, // trackId → year (empty = exclude card) + Map artists, // trackId → artist override (optional) + Map titles // trackId → title override (optional) +) {} diff --git a/src/main/java/de/oaa/libredeck/web/controller/PlaylistController.java b/src/main/java/de/oaa/libredeck/web/controller/PlaylistController.java new file mode 100644 index 0000000..4de3c54 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/PlaylistController.java @@ -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"; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/controller/SpotifyAuthController.java b/src/main/java/de/oaa/libredeck/web/controller/SpotifyAuthController.java new file mode 100644 index 0000000..8e0d2b1 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/SpotifyAuthController.java @@ -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 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); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/controller/SpotifyController.java b/src/main/java/de/oaa/libredeck/web/controller/SpotifyController.java new file mode 100644 index 0000000..04cdfc0 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/SpotifyController.java @@ -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 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 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())); + } + } +} diff --git a/src/main/java/de/oaa/libredeck/web/controller/TidalAuthController.java b/src/main/java/de/oaa/libredeck/web/controller/TidalAuthController.java new file mode 100644 index 0000000..7218fd9 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/TidalAuthController.java @@ -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 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); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/controller/TidalController.java b/src/main/java/de/oaa/libredeck/web/controller/TidalController.java new file mode 100644 index 0000000..9197850 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/controller/TidalController.java @@ -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 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 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())); + } + } +} diff --git a/src/main/java/de/oaa/libredeck/web/model/OAuthToken.java b/src/main/java/de/oaa/libredeck/web/model/OAuthToken.java new file mode 100644 index 0000000..0a168ef --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/model/OAuthToken.java @@ -0,0 +1,3 @@ +package de.oaa.libredeck.web.model; + +public record OAuthToken(String accessToken, long expiresIn) {} diff --git a/src/main/java/de/oaa/libredeck/web/model/PlaylistSummary.java b/src/main/java/de/oaa/libredeck/web/model/PlaylistSummary.java new file mode 100644 index 0000000..ecbd5b5 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/model/PlaylistSummary.java @@ -0,0 +1,3 @@ +package de.oaa.libredeck.web.model; + +public record PlaylistSummary(String id, String title, int trackCount, String coverUrl) {} diff --git a/src/main/java/de/oaa/libredeck/web/model/Track.java b/src/main/java/de/oaa/libredeck/web/model/Track.java new file mode 100644 index 0000000..b625589 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/model/Track.java @@ -0,0 +1,3 @@ +package de.oaa.libredeck.web.model; + +public record Track(String id, String title, String artist, String releaseYear, String streamingUrl) {} diff --git a/src/main/java/de/oaa/libredeck/web/service/GenerationJob.java b/src/main/java/de/oaa/libredeck/web/service/GenerationJob.java new file mode 100644 index 0000000..1a28528 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/GenerationJob.java @@ -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 tracks = null; + + public GenerationJob(String id) { + this.id = id; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/JobStore.java b/src/main/java/de/oaa/libredeck/web/service/JobStore.java new file mode 100644 index 0000000..695a6d5 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/JobStore.java @@ -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 jobs = new ConcurrentHashMap<>(); + + public GenerationJob create() { + evictExpired(); + GenerationJob job = new GenerationJob(UUID.randomUUID().toString()); + jobs.put(job.id, job); + return job; + } + + public Optional 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); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/MusicBrainzClient.java b/src/main/java/de/oaa/libredeck/web/service/MusicBrainzClient.java new file mode 100644 index 0000000..0ce19b2 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/MusicBrainzClient.java @@ -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 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("?", "\\?"); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/TrackNormalizer.java b/src/main/java/de/oaa/libredeck/web/service/TrackNormalizer.java new file mode 100644 index 0000000..1008e0d --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/TrackNormalizer.java @@ -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}; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicApiClient.java b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicApiClient.java new file mode 100644 index 0000000..2b885ac --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicApiClient.java @@ -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 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 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 fetchLibraryTracks(String playlistId) throws IOException { + return fetchTrackPages(API_BASE + "/me/library/playlists/" + playlistId + "/tracks", true); + } + + public List fetchUserPlaylists() throws IOException { + List 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 fetchTrackPages(String startUrl, boolean requireMut) throws IOException { + List 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 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)); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicAuthRequiredException.java b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicAuthRequiredException.java new file mode 100644 index 0000000..6a7e159 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicAuthRequiredException.java @@ -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"); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicProvider.java b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicProvider.java new file mode 100644 index 0000000..2730952 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicProvider.java @@ -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 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); + }; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenGenerator.java b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenGenerator.java new file mode 100644 index 0000000..27b007c --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenGenerator.java @@ -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); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenStore.java b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenStore.java new file mode 100644 index 0000000..9daba6c --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/applemusic/AppleMusicTokenStore.java @@ -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(); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/deezer/DeezerApiClient.java b/src/main/java/de/oaa/libredeck/web/service/deezer/DeezerApiClient.java new file mode 100644 index 0000000..d5e591c --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/deezer/DeezerApiClient.java @@ -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 fetchTracks(String playlistId) throws IOException { + List 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 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 fetchPlaylists(String accessToken) throws IOException { ... } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/deezer/DeezerProvider.java b/src/main/java/de/oaa/libredeck/web/service/deezer/DeezerProvider.java new file mode 100644 index 0000000..452c755 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/deezer/DeezerProvider.java @@ -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 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 fetchPlaylists(String accessToken) throws IOException { + // return apiClient.fetchPlaylists(accessToken); + // } + // @Override public List fetchTracks(String accessToken, String playlistId) throws IOException { + // return apiClient.fetchTracks(playlistId); // token currently unused + // } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/pdf/PdfCardGenerator.java b/src/main/java/de/oaa/libredeck/web/service/pdf/PdfCardGenerator.java new file mode 100644 index 0000000..1cd9b3e --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/pdf/PdfCardGenerator.java @@ -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 tracks) throws IOException { + return generate(playlistTitle, tracks, n -> {}); + } + + public byte[] generate(String playlistTitle, List 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 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 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 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; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/pdf/QrCodeGenerator.java b/src/main/java/de/oaa/libredeck/web/service/pdf/QrCodeGenerator.java new file mode 100644 index 0000000..91b706f --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/pdf/QrCodeGenerator.java @@ -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; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyApiClient.java b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyApiClient.java new file mode 100644 index 0000000..e6c9a50 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyApiClient.java @@ -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 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 fetchTracks(String playlistId) throws IOException { + List 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 fetchUserPlaylists() throws IOException { + List 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 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 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 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(); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyAuthRequiredException.java b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyAuthRequiredException.java new file mode 100644 index 0000000..2836d59 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyAuthRequiredException.java @@ -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"); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyProvider.java b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyProvider.java new file mode 100644 index 0000000..4940f24 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyProvider.java @@ -0,0 +1,58 @@ +package de.oaa.libredeck.web.service.spotify; + +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 SpotifyProvider implements StreamingProvider { + + // https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=... + private static final Pattern PLAYLIST_URL = Pattern.compile( + "open\\.spotify\\.com/playlist/([A-Za-z0-9]+)"); + // spotify:playlist:37i9dQZF1DXcBWIGoYBM5M + private static final Pattern PLAYLIST_URI = Pattern.compile( + "spotify:playlist:([A-Za-z0-9]+)"); + + private final SpotifyApiClient apiClient; + + @Override public String getId() { return "spotify"; } + @Override public String getDisplayName() { return "Spotify"; } + + @Override + public boolean supportsUrl(String url) { + if (url == null) return false; + return PLAYLIST_URL.matcher(url).find() || PLAYLIST_URI.matcher(url).find(); + } + + @Override + public String extractPlaylistId(String url) { + Matcher m = PLAYLIST_URL.matcher(url); + if (m.find()) return m.group(1); + m = PLAYLIST_URI.matcher(url); + if (m.find()) return m.group(1); + throw new IllegalArgumentException("Not a valid Spotify playlist URL: " + url); + } + + @Override + public String fetchPlaylistTitle(String playlistId) throws IOException { + return apiClient.fetchPlaylistTitle(playlistId); + } + + @Override + public List fetchTracks(String playlistId) throws IOException { + return apiClient.fetchTracks(playlistId); + } + + @Override + public String fetchTrackYear(String trackId) { + return apiClient.fetchTrackYear(trackId); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyTokenRefresher.java b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyTokenRefresher.java new file mode 100644 index 0000000..1f946c9 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyTokenRefresher.java @@ -0,0 +1,73 @@ +package de.oaa.libredeck.web.service.spotify; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.oaa.libredeck.web.config.SpotifyConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +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 +@Component +@RequiredArgsConstructor +public class SpotifyTokenRefresher { + + private static final String TOKEN_URL = "https://accounts.spotify.com/api/token"; + + private final SpotifyConfig config; + private final SpotifyTokenStore tokenStore; + + private final HttpClient http = HttpClient.newHttpClient(); + private final ObjectMapper mapper = new ObjectMapper(); + + /** Attempts to refresh the access token. Returns true if the token is now valid. */ + public boolean tryRefresh() { + String refreshToken = tokenStore.getRefreshToken(); + if (refreshToken == null || refreshToken.isBlank()) return false; + + 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=refresh_token&refresh_token=" + + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8))) + .build(); + + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + JsonNode json = mapper.readTree(resp.body()); + + if (json.has("error")) { + log.warn("Spotify token refresh failed: {}", resp.body()); + return false; + } + + tokenStore.setAccessToken(json.path("access_token").asText()); + int expiresIn = json.path("expires_in").asInt(3600); + tokenStore.setExpiresAt(System.currentTimeMillis() + (expiresIn - 60) * 1000L); + + // Spotify may return a new refresh token + String newRefresh = json.path("refresh_token").asText(""); + if (!newRefresh.isBlank()) tokenStore.setRefreshToken(newRefresh); + + log.info("Spotify token refreshed, expires in {}s", expiresIn); + return true; + + } catch (Exception e) { + log.warn("Spotify token refresh error: {}", e.getMessage()); + return false; + } + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyTokenStore.java b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyTokenStore.java new file mode 100644 index 0000000..49811ca --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/spotify/SpotifyTokenStore.java @@ -0,0 +1,22 @@ +package de.oaa.libredeck.web.service.spotify; + +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 SpotifyTokenStore { + + private String accessToken; + private String refreshToken; + private long expiresAt; + + public boolean hasValidToken() { + return accessToken != null && System.currentTimeMillis() < expiresAt; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/streaming/StreamingProvider.java b/src/main/java/de/oaa/libredeck/web/service/streaming/StreamingProvider.java new file mode 100644 index 0000000..d29f82e --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/streaming/StreamingProvider.java @@ -0,0 +1,44 @@ +package de.oaa.libredeck.web.service.streaming; + +import java.io.IOException; +import java.util.List; + +import de.oaa.libredeck.web.model.Track; + +public interface StreamingProvider { + + String getId(); + + String getDisplayName(); + + /** Returns true if this provider can handle the given playlist URL. */ + boolean supportsUrl(String url); + + /** Extracts the playlist ID from a provider URL. */ + String extractPlaylistId(String url); + + /** Fetches the playlist title by ID via the public API. */ + String fetchPlaylistTitle(String playlistId) throws IOException; + + /** Fetches all tracks for the given playlist ID via the public API. Year field is empty. */ + List fetchTracks(String playlistId) throws IOException; + + /** + * Looks up the release year for a single track by its provider-specific ID. + * Returns an empty string if no year can be determined. + * Default implementation returns empty; override in providers that support it. + */ + default String fetchTrackYear(String trackId) { return ""; } + + // ------------------------------------------------------------------------- + // OAuth methods – commented out until Deezer re-enables app registration. + // Re-enable together with AuthController and the OAuth flow in DeezerProvider + // when a Deezer app-id / app-secret becomes available. + // ------------------------------------------------------------------------- + // import de.libredeck.web.model.OAuthToken; + // import de.libredeck.web.model.PlaylistSummary; + // String buildAuthorizationUrl(String state); + // OAuthToken exchangeCode(String code) throws IOException; + // List fetchPlaylists(String accessToken) throws IOException; + // List fetchTracks(String accessToken, String playlistId) throws IOException; +} diff --git a/src/main/java/de/oaa/libredeck/web/service/streaming/StreamingProviderRegistry.java b/src/main/java/de/oaa/libredeck/web/service/streaming/StreamingProviderRegistry.java new file mode 100644 index 0000000..aa367e9 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/streaming/StreamingProviderRegistry.java @@ -0,0 +1,39 @@ +package de.oaa.libredeck.web.service.streaming; + +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class StreamingProviderRegistry { + + private final Map providers; + + public StreamingProviderRegistry(List providers) { + this.providers = providers.stream() + .collect(Collectors.toMap(StreamingProvider::getId, Function.identity())); + } + + public StreamingProvider get(String id) { + StreamingProvider provider = providers.get(id); + if (provider == null) { + throw new IllegalArgumentException("Unknown streaming provider: " + id); + } + return provider; + } + + public Optional findForUrl(String url) { + return providers.values().stream() + .filter(p -> p.supportsUrl(url)) + .findFirst(); + } + + public Collection getAll() { + return providers.values(); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/tidal/TidalApiClient.java b/src/main/java/de/oaa/libredeck/web/service/tidal/TidalApiClient.java new file mode 100644 index 0000000..bc6a826 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/tidal/TidalApiClient.java @@ -0,0 +1,234 @@ +package de.oaa.libredeck.web.service.tidal; + +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.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TidalApiClient { + + private static final String API_BASE = "https://openapi.tidal.com/v2"; + private static final long RATE_LIMIT = 200; // ms — Tidal is stricter + + private static final ThreadLocal TOKEN_OVERRIDE = new ThreadLocal<>(); + private static final ThreadLocal COUNTRY_OVERRIDE = new ThreadLocal<>(); + + private final HttpClient http = HttpClient.newHttpClient(); + private final ObjectMapper mapper = new ObjectMapper(); + private final TidalTokenStore tokenStore; + + private long lastRequest = 0; + + public void pinTokenForThread(String token, String countryCode) { + TOKEN_OVERRIDE.set(token); + COUNTRY_OVERRIDE.set(countryCode); + } + + public void clearThreadToken() { + TOKEN_OVERRIDE.remove(); + COUNTRY_OVERRIDE.remove(); + } + + public String fetchPlaylistTitle(String playlistId) throws IOException { + String cc = countryCode(); + JsonNode root = mapper.readTree(get(API_BASE + "/playlists/" + playlistId + "?countryCode=" + cc)); + return root.path("data").path("attributes").path("name").asText("Playlist " + playlistId); + } + + public List fetchTracks(String playlistId) throws IOException { + String cc = countryCode(); + List result = new ArrayList<>(); + String url = API_BASE + "/playlists/" + playlistId + "/relationships/items" + + "?countryCode=" + cc + "&include=items"; + + while (url != null) { + JsonNode root = mapper.readTree(get(url)); + log.debug("Tidal items page: {}", root.toString().substring(0, Math.min(500, root.toString().length()))); + + // Build a lookup map from included tracks + Map trackMap = new HashMap<>(); + JsonNode included = root.path("included"); + if (included.isArray()) { + for (JsonNode node : included) { + if ("tracks".equals(node.path("type").asText())) { + trackMap.put(node.path("id").asText(), node.path("attributes")); + } + } + } + + // Iterate data (item relationships) + JsonNode data = root.path("data"); + if (data.isArray()) { + for (JsonNode rel : data) { + JsonNode itemRef = rel.path("relationships").path("item").path("data"); + if (itemRef.isMissingNode()) continue; + String trackId = itemRef.path("id").asText(""); + if (trackId.isEmpty()) continue; + + JsonNode attrs = trackMap.get(trackId); + if (attrs == null || attrs.isMissingNode()) continue; + + String rawTitle = attrs.path("title").asText(""); + String version = attrs.path("version").asText(""); + if (!version.isBlank()) rawTitle = rawTitle + " (" + version + ")"; + + String rawArtist = ""; + JsonNode artists = attrs.path("artists"); + if (artists.isArray()) { + for (JsonNode a : artists) { + if (a.path("main").asBoolean(false) || rawArtist.isEmpty()) { + rawArtist = a.path("name").asText(""); + if (a.path("main").asBoolean(false)) break; + } + } + } + + String date = attrs.path("album").path("releaseDate").asText(""); + String year = date.length() >= 4 ? date.substring(0, 4) : ""; + + String streamUrl = "https://tidal.com/browse/track/" + trackId; + + String[] ta = TrackNormalizer.extractFeaturing(TrackNormalizer.cleanTitle(rawTitle), rawArtist); + result.add(new Track(trackId, ta[0], ta[1], year, streamUrl)); + } + } + + // JSON:API cursor-based pagination via links.next + JsonNode next = root.path("links").path("next"); + if (next.isMissingNode() || next.isNull() || next.asText("").isBlank()) { + url = null; + } else { + String nextStr = next.asText(); + // next may be relative or absolute + url = nextStr.startsWith("http") ? nextStr : API_BASE + nextStr; + } + } + + return result; + } + + public List fetchUserPlaylists() throws IOException { + String cc = countryCode(); + List result = new ArrayList<>(); + // filter[owners.id]=me → only playlists owned by the current user + String url = API_BASE + "/playlists?filter%5Bowners.id%5D=me&countryCode=" + cc + "&include=coverArt"; + + while (url != null) { + String raw = get(url); + log.info("Tidal playlists response: {}", raw.substring(0, Math.min(500, raw.length()))); + JsonNode root = mapper.readTree(raw); + + // Build cover art URL lookup from included images + Map coverUrls = new HashMap<>(); + JsonNode included = root.path("included"); + if (included.isArray()) { + for (JsonNode node : included) { + if ("images".equals(node.path("type").asText())) { + String imgId = node.path("id").asText(); + String imgUrl = node.path("attributes").path("url").asText(""); + if (!imgUrl.isEmpty()) coverUrls.put(imgId, imgUrl); + } + } + } + + JsonNode data = root.path("data"); + if (data.isArray()) { + for (JsonNode pl : data) { + String id = pl.path("id").asText(""); + if (id.isEmpty()) continue; + String name = pl.path("attributes").path("name").asText(""); + int total = pl.path("attributes").path("numberOfTracks").asInt(0); + + String cover = ""; + JsonNode coverData = pl.path("relationships").path("coverArt").path("data"); + if (coverData.isArray() && !coverData.isEmpty()) { + cover = coverUrls.getOrDefault(coverData.get(0).path("id").asText(""), ""); + } + + result.add(new PlaylistSummary(id, name, total, cover)); + } + } + + JsonNode next = root.path("links").path("next"); + if (next.isMissingNode() || next.isNull() || next.asText("").isBlank()) { + url = null; + } else { + String nextStr = next.asText(); + url = nextStr.startsWith("http") ? nextStr : API_BASE + nextStr; + } + } + + return result; + } + + // ── HTTP helpers ────────────────────────────────────────────────────────── + + private synchronized String get(String url) throws IOException { + String token = TOKEN_OVERRIDE.get(); + if (token == null) { + if (!tokenStore.hasValidToken()) throw new TidalAuthRequiredException(); + token = tokenStore.getAccessToken(); + } + rateLimitWait(); + + HttpResponse response = send(url, token); + if (response.statusCode() == 401) { + throw new TidalAuthRequiredException(); + } + if (response.statusCode() != 200) { + log.warn("Tidal API {} → {}: {}", url, response.statusCode(), response.body()); + throw new IOException("Tidal API error " + response.statusCode() + ": " + response.body()); + } + return response.body(); + } + + private HttpResponse send(String url, String token) throws IOException { + try { + return http.send( + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + token) + .header("Accept", "application/vnd.api+json") + .GET().build(), + HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request interrupted", e); + } + } + + private String countryCode() { + String cc = COUNTRY_OVERRIDE.get(); + if (cc != null && !cc.isBlank()) return cc; + cc = tokenStore.getCountryCode(); + return (cc != null && !cc.isBlank()) ? cc : "US"; + } + + 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(); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/tidal/TidalAuthRequiredException.java b/src/main/java/de/oaa/libredeck/web/service/tidal/TidalAuthRequiredException.java new file mode 100644 index 0000000..321d560 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/tidal/TidalAuthRequiredException.java @@ -0,0 +1,9 @@ +package de.oaa.libredeck.web.service.tidal; + +public class TidalAuthRequiredException extends RuntimeException { + private static final long serialVersionUID = 1741820022233299459L; + + public TidalAuthRequiredException() { + super("Tidal-Authentifizierung erforderlich"); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/tidal/TidalProvider.java b/src/main/java/de/oaa/libredeck/web/service/tidal/TidalProvider.java new file mode 100644 index 0000000..bbb90a7 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/tidal/TidalProvider.java @@ -0,0 +1,49 @@ +package de.oaa.libredeck.web.service.tidal; + +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 TidalProvider implements StreamingProvider { + + // https://tidal.com/browse/playlist/uuid or https://listen.tidal.com/playlist/uuid + private static final Pattern PLAYLIST_URL = Pattern.compile( + "tidal\\.com(?:/browse)?/playlist/([\\w\\-]+)", + Pattern.CASE_INSENSITIVE); + + private final TidalApiClient apiClient; + + @Override public String getId() { return "tidal"; } + @Override public String getDisplayName() { return "Tidal"; } + + @Override + public boolean supportsUrl(String url) { + if (url == null) return false; + return PLAYLIST_URL.matcher(url).find(); + } + + @Override + public String extractPlaylistId(String url) { + Matcher m = PLAYLIST_URL.matcher(url); + if (m.find()) return m.group(1); + throw new IllegalArgumentException("Not a valid Tidal playlist URL: " + url); + } + + @Override + public String fetchPlaylistTitle(String playlistId) throws IOException { + return apiClient.fetchPlaylistTitle(playlistId); + } + + @Override + public List fetchTracks(String playlistId) throws IOException { + return apiClient.fetchTracks(playlistId); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/tidal/TidalTokenStore.java b/src/main/java/de/oaa/libredeck/web/service/tidal/TidalTokenStore.java new file mode 100644 index 0000000..83a2b50 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/tidal/TidalTokenStore.java @@ -0,0 +1,25 @@ +package de.oaa.libredeck.web.service.tidal; + +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 TidalTokenStore { + + private String accessToken; + private String refreshToken; + private long expiresAt; + private String codeVerifier; + private String redirectUri; + private String countryCode = "DE"; + + public boolean hasValidToken() { + return accessToken != null && System.currentTimeMillis() < expiresAt; + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/youtube/YouTubeApiClient.java b/src/main/java/de/oaa/libredeck/web/service/youtube/YouTubeApiClient.java new file mode 100644 index 0000000..6d819f3 --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/youtube/YouTubeApiClient.java @@ -0,0 +1,152 @@ +package de.oaa.libredeck.web.service.youtube; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.oaa.libredeck.web.config.YouTubeConfig; +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.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class YouTubeApiClient { + + private static final String API_BASE = "https://www.googleapis.com/youtube/v3"; + private static final long RATE_LIMIT = 100; // ms + private static final int MAX_TRACKS = 500; + + private final HttpClient http = HttpClient.newHttpClient(); + private final ObjectMapper mapper = new ObjectMapper(); + private final YouTubeConfig config; + + private long lastRequest = 0; + + public String fetchPlaylistTitle(String playlistId) throws IOException { + String url = API_BASE + "/playlists?part=snippet&id=" + enc(playlistId) + "&key=" + enc(config.getApiKey()); + JsonNode root = mapper.readTree(get(url)); + return root.path("items").path(0).path("snippet").path("title").asText("Playlist " + playlistId); + } + + public List fetchTracks(String playlistId) throws IOException { + List result = new ArrayList<>(); + String pageToken = null; + + do { + String url = API_BASE + "/playlistItems?part=snippet&playlistId=" + enc(playlistId) + + "&maxResults=50" + (pageToken != null ? "&pageToken=" + enc(pageToken) : "") + + "&key=" + enc(config.getApiKey()); + JsonNode root = mapper.readTree(get(url)); + + // Enforce limit on first page using reported total + if (result.isEmpty()) { + int total = root.path("pageInfo").path("totalResults").asInt(0); + if (total > MAX_TRACKS) { + throw new IllegalArgumentException( + "Diese Playlist enthält " + total + " Einträge. " + + "YouTube-Playlists werden nur bis " + MAX_TRACKS + " Titel unterstützt."); + } + } + + for (JsonNode item : root.path("items")) { + JsonNode snippet = item.path("snippet"); + String videoId = snippet.path("resourceId").path("videoId").asText(""); + if (videoId.isEmpty()) continue; + String rawTitle = snippet.path("title").asText(""); + if (rawTitle.equals("Private video") || rawTitle.equals("Deleted video")) continue; + String channel = snippet.path("videoOwnerChannelTitle").asText(""); + + String artist; + String title; + + // Primary source: parse title using known separators + // 1) "Artist - Title" (spaces around dash) + // 2) "Artist-Title" (no spaces around dash) + // 3) "Artist: Title" (colon, e.g. "Metallica: Enter Sandman") + // 4) fallback: channel name as artist + int sep = rawTitle.indexOf(" - "); + if (sep > 0) { + artist = rawTitle.substring(0, sep).trim(); + title = rawTitle.substring(sep + 3).trim(); + } else { + int bareDash = rawTitle.indexOf("-"); + if (bareDash > 0) { + artist = rawTitle.substring(0, bareDash).trim(); + title = rawTitle.substring(bareDash + 1).trim(); + } else { + int colon = rawTitle.indexOf(": "); + if (colon > 0) { + artist = rawTitle.substring(0, colon).trim(); + title = rawTitle.substring(colon + 2).trim(); + } else { + artist = channel.replaceAll("(?i)\\s*-\\s*topic$", "") + .replaceAll("(?i)VEVO$", "") + .trim(); + title = rawTitle; + } + } + } + + title = TrackNormalizer.cleanTitle(title); + String[] ta = TrackNormalizer.extractFeaturing(title, artist); + result.add(new Track(videoId, ta[0], ta[1], "", + "https://www.youtube.com/watch?v=" + videoId)); + } + + JsonNode next = root.path("nextPageToken"); + pageToken = next.isMissingNode() || next.isNull() ? null : next.asText(); + } while (pageToken != null); + + return result; + } + + /** Year is resolved via MusicBrainz in GenerateController; nothing to do here. */ + public String fetchTrackYear(String videoId) { + return ""; + } + + // ── HTTP ───────────────────────────────────────────────────────────────── + + private synchronized String get(String url) throws IOException { + long wait = RATE_LIMIT - (System.currentTimeMillis() - lastRequest); + if (wait > 0) { + try { Thread.sleep(wait); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request interrupted", e); + } + } + lastRequest = System.currentTimeMillis(); + + try { + HttpResponse resp = http.send( + HttpRequest.newBuilder().uri(URI.create(url)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + JsonNode err = mapper.readTree(resp.body()).path("error"); + String msg = err.isMissingNode() ? resp.body() + : err.path("message").asText(resp.body()); + throw new IOException("YouTube API error " + resp.statusCode() + ": " + msg); + } + return resp.body(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request interrupted", e); + } + } + + private static String enc(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/de/oaa/libredeck/web/service/youtube/YouTubeProvider.java b/src/main/java/de/oaa/libredeck/web/service/youtube/YouTubeProvider.java new file mode 100644 index 0000000..71bc43e --- /dev/null +++ b/src/main/java/de/oaa/libredeck/web/service/youtube/YouTubeProvider.java @@ -0,0 +1,56 @@ +package de.oaa.libredeck.web.service.youtube; + +import de.oaa.libredeck.web.config.YouTubeConfig; +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 YouTubeProvider implements StreamingProvider { + + // https://www.youtube.com/playlist?list=PLxxxxx + // https://music.youtube.com/playlist?list=PLxxxxx + // https://www.youtube.com/watch?v=xxx&list=PLxxxxx (video playing inside a playlist) + private static final Pattern PLAYLIST_URL = Pattern.compile( + "(?:(?:www\\.|music\\.)?youtube\\.com).*[?&]list=([A-Za-z0-9_-]+)"); + + private final YouTubeApiClient apiClient; + private final YouTubeConfig config; + + @Override public String getId() { return "youtube"; } + @Override public String getDisplayName() { return "YouTube"; } + + @Override + public boolean supportsUrl(String url) { + return url != null && !config.getApiKey().isBlank() && 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 YouTube playlist URL: " + url); + return m.group(1); + } + + @Override + public String fetchPlaylistTitle(String playlistId) throws IOException { + return apiClient.fetchPlaylistTitle(playlistId); + } + + @Override + public List fetchTracks(String playlistId) throws IOException { + return apiClient.fetchTracks(playlistId); + } + + @Override + public String fetchTrackYear(String trackId) { + return apiClient.fetchTrackYear(trackId); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..cad8dca --- /dev/null +++ b/src/main/resources/application.yml @@ -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} diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..2f0276e --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,1165 @@ +/* ── Reset & tokens ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + /* Brand colours extracted from the LibreDeck logo */ + --teal: #1a9aaa; + --teal-h: #22b5c8; + --teal-dark: #136e7a; + --green: #46c14a; + --green-h: #55d659; + --grad: linear-gradient(135deg, #1a9aaa 0%, #46c14a 100%); + --grad-glow: linear-gradient(135deg, rgba(26,154,170,.35) 0%, rgba(70,193,74,.25) 100%); + + /* Backgrounds – dark with subtle teal tint */ + --bg: #080d0e; + --bg-2: #0c1415; + --surface: #101c1e; + --surface-2: #162224; + --border: #1c3236; + --border-h: #2a4e54; + + /* Text */ + --text: #dff0f2; + --text-muted: #6e9ea6; + --text-dim: #3a6068; + + --radius: 14px; + --radius-sm: 8px; +} + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } +strong { color: var(--text); } + +/* ── Layout ── */ +.section-inner { + max-width: 1000px; + margin: 0 auto; + padding: 0 1.5rem; +} +.section-title { + font-size: clamp(1.5rem, 3vw, 2rem); + font-weight: 800; + letter-spacing: -.5px; + margin-bottom: .5rem; +} +.section-sub { + color: var(--text-muted); + font-size: 1.05rem; + margin-bottom: 2.5rem; +} +.accent { color: var(--teal); } + +/* ── Navbar ── */ +.navbar { + position: sticky; + top: 0; + z-index: 100; + background: rgba(8,13,14,.88); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--border); +} +.nav-inner { + max-width: 1000px; + margin: 0 auto; + padding: .7rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.nav-logo { + height: 40px; + width: auto; + display: block; +} + +/* ── Buttons ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; + background: var(--grad); + color: #fff; + border: none; + border-radius: var(--radius-sm); + padding: .6rem 1.25rem; + font-size: .95rem; + font-weight: 700; + cursor: pointer; + transition: filter .15s, box-shadow .15s; + white-space: nowrap; + line-height: 1; +} +.btn:hover { + filter: brightness(1.12); + box-shadow: 0 0 22px rgba(26,154,170,.4), 0 0 22px rgba(70,193,74,.2); +} +.btn-sm { font-size: .82rem; padding: .45rem .9rem; border-radius: 6px; } +.btn-lg { font-size: 1rem; padding: .8rem 1.9rem; border-radius: 10px; } + +/* ── Hero ── */ +.hero { + padding: 6rem 0 5rem; + background: + radial-gradient(ellipse 55% 45% at 65% 0%, rgba(26,154,170,.13) 0%, transparent 70%), + radial-gradient(ellipse 35% 30% at 80% 80%, rgba(70,193,74,.08) 0%, transparent 70%); +} +.hero-inner { + max-width: 1000px; + margin: 0 auto; + padding: 0 1.5rem; + display: grid; + grid-template-columns: 1fr auto; + gap: 4rem; + align-items: center; +} +.hero-brand { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.hero-brand-logo { + width: 260px; + height: auto; + filter: drop-shadow(0 0 32px rgba(26,154,170,.5)) drop-shadow(0 0 64px rgba(70,193,74,.25)); + animation: logoPulse 5s ease-in-out infinite; +} +@keyframes logoPulse { + 0%, 100% { + filter: drop-shadow(0 0 32px rgba(26,154,170,.5)) drop-shadow(0 0 64px rgba(70,193,74,.25)); + } + 50% { + filter: drop-shadow(0 0 48px rgba(26,154,170,.75)) drop-shadow(0 0 96px rgba(70,193,74,.4)); + } +} +.hero-eyebrow { + font-size: .78rem; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--teal); + margin-bottom: 1rem; +} +.hero-text h1 { + font-size: clamp(2rem, 5vw, 3.2rem); + font-weight: 900; + letter-spacing: -1.5px; + line-height: 1.15; + margin-bottom: 1.25rem; +} +.hero-sub { + color: var(--text-muted); + font-size: 1.1rem; + max-width: 480px; + margin-bottom: 2rem; + line-height: 1.7; +} + +/* ── Card Mockups ── */ +.hero-cards { + display: flex; + gap: 16px; + align-items: flex-start; + flex-shrink: 0; +} +.card-mockup { + width: 120px; + height: 120px; + border-radius: 10px; + border: 1px solid rgba(26,154,170,.3); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + position: relative; + overflow: hidden; + background: #fff; + color: #111; + box-shadow: 0 8px 32px rgba(0,0,0,.5), 0 0 0 1px rgba(26,154,170,.15); +} +.card-mockup.small { width: 90px; height: 90px; border-radius: 7px; } +.card-front { margin-top: 20px; } + +.mock-year { font-size: 22px; font-weight: 900; color: #1a7a8a; letter-spacing: -1px; line-height: 1; } +.mock-divider { width: 70%; height: 1px; background: #c5e6ea; margin: 2px 0; } +.mock-artist { font-size: 8.5px; color: #555; text-align: center; padding: 0 6px; line-height: 1.2; } +.mock-title { font-size: 8.5px; font-weight: 700; color: #1a7a8a; text-align: center; padding: 0 6px; line-height: 1.2; } +.mock-brand { position: absolute; bottom: 4px; font-size: 6px; color: #aac; letter-spacing: .05em; } + +.card-mockup.small .mock-year { font-size: 16px; } +.card-mockup.small .mock-artist, +.card-mockup.small .mock-title { font-size: 6.5px; } + +.mock-qr { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; } +.qr-grid { + width: 72px; height: 72px; + background-image: + linear-gradient(#1a7a8a 0, #1a7a8a 100%), + linear-gradient(#1a7a8a 0, #1a7a8a 100%), + linear-gradient(#1a7a8a 0, #1a7a8a 100%), + repeating-linear-gradient(90deg, transparent 0, transparent 6px, #1a7a8a 6px, #1a7a8a 8px), + repeating-linear-gradient(0deg, transparent 0, transparent 6px, #1a7a8a 6px, #1a7a8a 8px); + background-size: 18px 18px, 18px 18px, 18px 18px, 100% 100%, 100% 100%; + background-position: 0 0, calc(100% - 0px) 0, 0 calc(100% - 0px), 0 0, 0 0; + background-repeat: no-repeat, no-repeat, no-repeat, repeat, repeat; + opacity: .8; + border: 1px solid #c5e6ea; + border-radius: 2px; +} + +/* ── How it works ── */ +.how-section { + padding: 5rem 0; + background: var(--bg-2); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.steps-viewport { + display: grid; + overflow: hidden; +} +.how-steps { + grid-column: 1; + grid-row: 1; + transition: transform .35s ease; +} +.steps { + display: flex; + align-items: stretch; + gap: 1rem; + margin-top: 2.5rem; + flex-wrap: wrap; +} +.step { + flex: 1; + min-width: 200px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.75rem 1.5rem; + transition: border-color .2s; +} +.step:hover { border-color: var(--border-h); } + +.step-icon { + width: 44px; height: 44px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(26,154,170,.15), rgba(70,193,74,.1)); + border: 1px solid rgba(26,154,170,.25); + display: flex; align-items: center; justify-content: center; + margin-bottom: 1.25rem; + color: var(--teal); +} +.step-icon svg { width: 22px; height: 22px; } +.step h3 { font-size: 1rem; font-weight: 700; margin-bottom: .5rem; color: var(--text); } +.step p { font-size: .9rem; color: var(--text-muted); line-height: 1.55; margin: 0; } + +.step-arrow { + font-size: 1.5rem; + color: var(--text-dim); + align-self: center; + flex-shrink: 0; + margin-top: -1rem; +} + +/* ── Card detail ── */ +.detail-section { padding: 5rem 0; } +.detail-inner { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + align-items: center; +} +.detail-text h2 { + font-size: clamp(1.4rem, 2.5vw, 1.9rem); + font-weight: 800; + letter-spacing: -.5px; + margin-bottom: 1.75rem; +} +.detail-list { list-style: none; display: flex; flex-direction: column; gap: 1.25rem; } +.detail-list li { display: flex; gap: 1rem; align-items: flex-start; } +.detail-icon { + flex-shrink: 0; + width: 34px; height: 34px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); + display: flex; align-items: center; justify-content: center; + font-size: .7rem; font-weight: 800; + color: var(--teal); +} +.detail-list strong { display: block; font-size: .95rem; margin-bottom: .2rem; } +.detail-list p { font-size: .875rem; color: var(--text-muted); margin: 0; line-height: 1.5; } + +/* ── Card grid: 2 × 3, each card slightly tilted ── */ +.preview-stack { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: 10px; + padding: 8px; +} +.preview-stack .card-mockup:nth-child(1) { transform: rotate(-4deg) translate(0, 3px); } +.preview-stack .card-mockup:nth-child(2) { transform: rotate( 3deg) translate(0, 6px); } +.preview-stack .card-mockup:nth-child(3) { transform: rotate( 5deg) translate(2px, -4px); } +.preview-stack .card-mockup:nth-child(4) { transform: rotate(-3deg) translate(0, -2px); } +.preview-stack .card-mockup:nth-child(5) { transform: rotate(-2deg) translate(-3px, 5px); } +.preview-stack .card-mockup:nth-child(6) { transform: rotate( 4deg) translate(1px, -3px); } + +/* ── Generator ── */ +.generator-section { + padding: 5rem 0 6rem; + background: var(--bg-2); + border-top: 1px solid var(--border); +} +.generator-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2.5rem; + box-shadow: 0 0 60px rgba(26,154,170,.06); +} +.field-label { + display: block; + font-size: .82rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .07em; + margin-bottom: .6rem; +} +.input-row { display: flex; gap: .75rem; flex-wrap: wrap; } +.url-input { + flex: 1; + min-width: 200px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 1rem; + padding: .7rem 1rem; + outline: none; + transition: border-color .15s, box-shadow .15s; +} +.url-input::placeholder { color: var(--text-dim); } +.url-input:focus { + border-color: var(--teal); + box-shadow: 0 0 0 3px rgba(26,154,170,.2); +} + +/* ── Hint details ── */ +.hint-details { + margin-top: 1.5rem; + border-top: 1px solid var(--border); + padding-top: 1.25rem; +} +.hint-details summary { + font-size: .88rem; + color: var(--text-muted); + cursor: pointer; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: .4rem; +} +.hint-details summary::before { + content: '▶'; + font-size: .65rem; + color: var(--teal); + transition: transform .15s; +} +.hint-details[open] summary::before { transform: rotate(90deg); } +.hint-steps { + color: var(--text-muted); + font-size: .88rem; + padding-left: 1.5rem; + margin: .75rem 0; +} +.hint-steps li { margin-bottom: .3rem; } +.hint-example { font-size: .82rem; color: var(--text-muted); margin: 0; } +.hint-example code { + background: var(--bg); + border: 1px solid var(--border); + padding: .15rem .5rem; + border-radius: 5px; + font-size: .8rem; +} + +/* ── Review section ── */ +.review-section { + margin-top: 2rem; + border-top: 1px solid var(--border); + padding-top: 1.75rem; +} +.review-heading { + font-size: 1.05rem; + font-weight: 700; + color: var(--text); + margin-bottom: .5rem; +} +.review-sub { + font-size: .88rem; + color: var(--text-muted); + margin-bottom: 1.25rem; + line-height: 1.55; +} +.review-table { + display: flex; + flex-direction: column; + gap: .5rem; + margin-bottom: 1.5rem; +} +.review-row { + display: grid; + grid-template-columns: 1fr 1.5fr 80px auto; + gap: .5rem; + align-items: center; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: .5rem .75rem; +} +/* YouTube rows have an extra swap button between artist and title columns */ +.review-row:has(.btn-swap) { + grid-template-columns: 1fr auto 1.5fr 80px auto; +} +.btn-swap { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + padding: .2rem .3rem; + line-height: 1; + border-radius: 4px; + transition: color .15s; +} +.btn-swap:hover { color: var(--accent); } +.review-artist-input, +.review-title-input { + font-size: .85rem; + padding: .3rem .5rem; + min-width: 0; +} +.review-row .year-input { + min-width: 0; + text-align: center; + padding: .45rem .5rem; + font-size: .9rem; +} +.btn-check { + background: transparent; + border: 1px solid var(--border-h); + color: var(--text-muted); + border-radius: 6px; + padding: .35rem .7rem; + font-size: .8rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: border-color .15s, color .15s; + line-height: 1; +} +.btn-check:hover:not(:disabled) { border-color: var(--teal); color: var(--teal); } +.btn-check:disabled { opacity: .45; cursor: default; } + +.year-suggestion { + display: flex; + align-items: center; + gap: .35rem; + font-size: .8rem; + color: var(--text-muted); + white-space: nowrap; +} +.suggestion-year { color: var(--teal); font-weight: 700; } +.suggestion-accept, .suggestion-reject { + background: transparent; + border: 1px solid var(--border-h); + border-radius: 4px; + padding: .2rem .45rem; + font-size: .75rem; + cursor: pointer; + line-height: 1; + transition: background .12s, color .12s; +} +.suggestion-accept { color: var(--green); } +.suggestion-accept:hover { background: rgba(70,193,74,.15); } +.suggestion-reject { color: var(--text-muted); } +.suggestion-reject:hover { background: rgba(255,255,255,.06); } + +.check-cell { + display: flex; + align-items: center; + gap: .5rem; +} + +.review-row--missing { + border-color: rgba(180,120,30,.4); + background: rgba(180,120,30,.06); +} +.year-input--missing { + border-color: rgba(180,120,30,.5) !important; +} +.review-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: .75rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} +.btn-play { + background: linear-gradient(135deg, var(--teal-dark) 0%, var(--teal) 100%); +} + +/* ── Play dialog ── */ +.play-dialog-inner { + position: relative; + background: var(--surface); + border: 1px solid var(--border-h); + border-radius: var(--radius); + padding: 2.5rem 2rem 2rem; + width: min(480px, 96vw); + display: flex; + flex-direction: column; + align-items: center; + gap: .5rem; +} +.play-close { + position: absolute; + top: .9rem; + right: 1rem; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1.2rem; + cursor: pointer; + line-height: 1; + padding: .25rem .5rem; + border-radius: 6px; + transition: color .15s; +} +.play-close:hover { color: var(--text); } +.play-remaining { + font-size: .82rem; + color: var(--text-dim); + margin-bottom: .25rem; + align-self: flex-end; +} + +/* card flip */ +#playCardWrap { + perspective: 900px; +} +.play-card { + width: min(260px, 72vw); + height: min(260px, 72vw); + position: relative; + transform-style: preserve-3d; + transition: transform .55s cubic-bezier(.4,0,.2,1); +} +.play-card.flipped { transform: rotateY(180deg); } +.play-face { + position: absolute; + inset: 0; + backface-visibility: hidden; + background: #ffffff; + border: 1.5px solid #1a9aaa; + border-radius: 4px; + overflow: hidden; +} +.play-front { + display: flex; + align-items: center; + justify-content: center; +} +.play-back { transform: rotateY(180deg); } +.play-qr { + width: 200px; + height: 200px; + flex-shrink: 0; +} +.play-qr img { + display: block; + width: 100%; + height: 100%; +} +/* Back face — absolute layout matching PDF proportions */ +.play-back-year { + position: absolute; + left: 0; right: 0; + top: 22%; + text-align: center; + font-size: 1.75rem; + font-weight: 800; + color: #136e7a; + margin: 0; + letter-spacing: -.5px; +} +.play-back-divider { + position: absolute; + top: 39%; + left: 8%; right: 8%; + border: none; + border-top: 1px solid #c5e6ea; + margin: 0; +} +.play-back-artist { + position: absolute; + top: 43%; + left: 6%; right: 6%; + text-align: center; + font-size: .82rem; + color: #555555; + margin: 0; + line-height: 1.35; +} +.play-back-title { + position: absolute; + top: 62%; + left: 6%; right: 6%; + text-align: center; + font-size: .9rem; + font-weight: 700; + color: #1a9aaa; + margin: 0; + line-height: 1.35; +} +.play-brand { + position: absolute; + bottom: 5%; + left: 0; right: 0; + text-align: center; + font-size: .55rem; + color: #aaaacc; + letter-spacing: .06em; + text-transform: uppercase; +} +.play-actions { + display: flex; + gap: .75rem; + margin-top: 1.25rem; + justify-content: center; +} +.bulk-progress { + display: flex; + align-items: center; + gap: .75rem; + margin-bottom: .25rem; +} +.bulk-progress-label { + font-size: .85rem; + color: var(--text-muted); + white-space: nowrap; +} +.review-row--updated { + border-color: rgba(26,154,170,.45); + background: rgba(26,154,170,.06); +} +.btn-outline { + background: transparent; + border: 1px solid var(--border-h); + color: var(--text-muted); + border-radius: var(--radius-sm); + padding: .6rem 1.25rem; + font-size: .95rem; + font-weight: 700; + cursor: pointer; + transition: border-color .15s, color .15s; + white-space: nowrap; + line-height: 1; +} +.btn-outline:hover { border-color: var(--teal); color: var(--teal); } + +/* ── Error ── */ +.error-msg { + background: rgba(180,30,30,.12); + border: 1px solid rgba(180,30,30,.35); + color: #ffaaaa; + border-radius: var(--radius-sm); + padding: .8rem 1rem; + margin-top: 1rem; + font-size: .9rem; +} + +/* ── Loading overlay ── */ +#loading-overlay { + position: fixed; + inset: 0; + background: rgba(8,13,14,.92); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.25rem; + z-index: 999; +} +#loading-overlay p { color: var(--text); font-size: 1.1rem; margin: 0; } +.loading-sub { color: var(--text-muted) !important; font-size: .9rem !important; } + +.btn-cancel { + background: transparent; + border: 1px solid var(--border-h); + color: var(--text-muted); + border-radius: var(--radius-sm); + padding: .45rem 1.1rem; + font-size: .85rem; + font-weight: 600; + cursor: pointer; + transition: border-color .15s, color .15s; + margin-top: .25rem; +} +.btn-cancel:hover { border-color: var(--text-muted); color: var(--text); } + +.spotify-auth-box { + margin-top: 1.25rem; + padding: 1rem 1.25rem; + border: 1px solid #1db954; + border-radius: var(--radius-sm); + background: rgba(29,185,84,.07); + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} +.spotify-auth-box p { margin: 0; font-size: .9rem; color: var(--text-muted); flex: 1; } +.spotify-btn { background: #1db954; } +.spotify-btn:hover { filter: brightness(1.1); } +.tidal-btn { background: #000; border: 1px solid #444; } +.tidal-btn:hover { background: #111; } + +#progressWrap { + display: flex; + flex-direction: column; + align-items: center; + gap: .6rem; + width: 260px; +} +.progress-bar-bg { + width: 100%; + height: 6px; + background: var(--border); + border-radius: 99px; + overflow: hidden; +} +.progress-bar-fill { + height: 100%; + width: 0%; + background: var(--grad); + border-radius: 99px; + transition: width .35s ease; +} + +.spinner { + width: 44px; height: 44px; + border: 3px solid var(--border); + border-top-color: var(--teal); + border-right-color: var(--green); + border-radius: 50%; + animation: spin .75s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Modal dialog ── */ +#deezerNotice, +#bulkCheckDialog { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} +.dialog-backdrop { + position: absolute; + inset: 0; + background: rgba(8,13,14,.75); + backdrop-filter: blur(4px); +} +.dialog-box { + position: relative; + background: var(--surface); + border: 1px solid var(--border-h); + border-radius: var(--radius); + padding: 2rem 2rem 1.75rem; + max-width: 460px; + width: calc(100% - 2rem); + box-shadow: 0 24px 64px rgba(0,0,0,.6), 0 0 0 1px rgba(26,154,170,.15); +} +.dialog-title { + font-size: 1.05rem; + font-weight: 700; + color: var(--text); + margin-bottom: 1rem; +} +.dialog-body { + font-size: .9rem; + color: var(--text-muted); + line-height: 1.65; + margin-bottom: 1.5rem; +} +.dialog-body strong { color: var(--text); } +.dialog-actions { display: flex; justify-content: flex-end; } + +/* ── Footer ── */ +.site-footer { + background: var(--bg); + border-top: 1px solid var(--border); + padding: 2.5rem 0; +} +.footer-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.footer-logo-img { height: 32px; width: auto; opacity: .8; } +.site-footer p { color: var(--text-dim); font-size: .82rem; margin: 0; } + +/* ── Service Topbar ── */ +.service-topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: var(--bg); + border-bottom: 1px solid var(--border); + padding: .825rem 0; +} +body { padding-top: 4rem; } +.topbar-inner { + display: flex; + align-items: center; + justify-content: center; + gap: 1.875rem; +} +.topbar-service { + display: flex; + align-items: center; + gap: .75rem; + min-width: 165px; + justify-content: center; +} +.topbar-icon { + width: 30px; + height: 30px; + border-radius: 6px; + object-fit: contain; +} +.topbar-service-name { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); +} +.topbar-nav { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font-size: 1.5rem; + padding: .45rem 1.125rem; + transition: background .15s, border-color .15s; + line-height: 1; +} +.topbar-nav:hover { background: var(--surface); border-color: var(--teal); } + +.service-hint { + font-size: .9rem; + color: var(--text-muted); + margin-bottom: 1rem; + text-align: center; +} +.gen-viewport { + display: grid; + overflow: hidden; +} +.service-panel { + grid-column: 1; + grid-row: 1; + transition: transform .35s ease; +} +#panelSpotify, #panelTidal, #panelApple { text-align: center; } +.spotify-url-row { + display: flex; + gap: .5rem; + margin-top: 1rem; + text-align: left; +} + +/* ── Playlist Picker ── */ +.picker-label { font-size: .85rem; color: var(--text-muted); margin: 1rem 0 0; } +.picker-header { + display: flex; + align-items: center; + justify-content: space-between; + margin: 1rem 0 .25rem; +} +.btn-refresh { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + padding: .15rem .45rem; + line-height: 1; + transition: color .15s, border-color .15s; +} +.btn-refresh:hover { color: var(--text); border-color: var(--teal); } + +/* ── Carousel ── */ +.playlist-carousel { + display: flex; + align-items: stretch; + gap: 6px; + margin-top: .5rem; +} +.carousel-viewport { + container-type: inline-size; /* enables cqw on children */ + flex: 1; + overflow: hidden; + min-width: 0; +} +.carousel-track { + display: flex; + flex-wrap: nowrap; + gap: 10px; + transition: transform .35s cubic-bezier(.25,.46,.45,.94); + will-change: transform; +} +/* 5 cards per row – width derived from the viewport container, not the track */ +.carousel-track .playlist-card { + flex-shrink: 0; + width: calc((100cqw - 4 * 10px) / 5); +} +@container (max-width: 500px) { + .carousel-track .playlist-card { + width: calc((100cqw - 2 * 10px) / 3); + } +} +.carousel-btn { + flex-shrink: 0; + width: 34px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font-size: 1.3rem; + line-height: 1; + transition: background .15s, border-color .15s, opacity .15s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.carousel-btn:hover:not(:disabled) { background: var(--surface); border-color: var(--teal); } +.carousel-btn:disabled { opacity: .22; cursor: default; } + +.playlist-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: .55rem; + cursor: pointer; + transition: border-color .15s, background .15s; + display: flex; + flex-direction: column; + gap: .35rem; + flex-shrink: 0; + min-width: 0; +} +.playlist-card:hover { border-color: var(--teal); background: var(--surface); } +.playlist-card img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; background: var(--surface-2); display: block; } +.playlist-card-nocover { + width: 100%; + aspect-ratio: 1; + border-radius: 4px; + background: var(--surface-2); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.6rem; +} +.playlist-card-name { + font-size: .78rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* constrain URL input rows and single-action panels to a readable width */ +.spotify-url-row { max-width: 600px; margin-left: auto; margin-right: auto; } +#panelDeezer .input-row, +#panelDeezer .hint-details { max-width: 600px; margin-left: auto; margin-right: auto; } +#panelYoutube .input-row, +#panelYoutube .hint-details { max-width: 600px; margin-left: auto; margin-right: auto; } +.youtube-notice { + font-size: .85rem; + color: var(--text-muted); + margin: .75rem auto 0; + max-width: 600px; + text-align: center; +} + +/* ── Responsive ── */ +@media (max-width: 760px) { + .hero-inner { grid-template-columns: 1fr; gap: 2.5rem; } + .hero-brand { justify-content: center; } + .hero-brand-logo { width: 200px; } + .hero-cards { justify-content: center; } + .detail-inner { grid-template-columns: 1fr; gap: 3rem; } + .steps { flex-direction: column; } + .step-arrow { display: none; } + .hero { padding: 4rem 0 3rem; } + .generator-card { padding: 1.75rem 1rem; } + .review-row { grid-template-columns: 1fr 70px auto; } + .review-artist-input { display: none; } +} + +/* ── Mobile scan FAB ── */ +.mobile-scan-btn { + display: none; /* hidden on desktop */ +} +@media (max-width: 768px) { + .mobile-scan-btn { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + bottom: 1.75rem; + left: 50%; + transform: translateX(-50%); + z-index: 400; + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(135deg, var(--teal), var(--teal-dark)); + border: none; + cursor: pointer; + color: #fff; + box-shadow: 0 4px 20px rgba(26,154,170,.55); + transition: transform .15s, box-shadow .15s; + } + .mobile-scan-btn:active { + transform: translateX(-50%) scale(.93); + box-shadow: 0 2px 10px rgba(26,154,170,.4); + } +} + +/* ── Mobile scanner dialog ── */ +.scan-close-btn { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 10; + background: rgba(255,255,255,.12); + border: none; + color: #fff; + font-size: 1.3rem; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.scan-viewport { + position: relative; + width: 100vw; + height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +/* semi-dark vignette around the guide box */ +.scan-viewport::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse 55% 55% at 50% 48%, + transparent 40%, rgba(0,0,0,.55) 100%); + pointer-events: none; + z-index: 1; +} +.scan-guide { + position: absolute; + width: min(260px, 70vw); + height: min(260px, 70vw); + z-index: 2; + pointer-events: none; +} +.scan-corner { + position: absolute; + width: 22px; + height: 22px; + border-color: #1a9aaa; + border-style: solid; +} +.scan-tl { top: 0; left: 0; border-width: 3px 0 0 3px; } +.scan-tr { top: 0; right: 0; border-width: 3px 3px 0 0; } +.scan-bl { bottom: 0; left: 0; border-width: 0 0 3px 3px; } +.scan-br { bottom: 0; right: 0; border-width: 0 3px 3px 0; } +.scan-hint { + position: absolute; + bottom: 3rem; + left: 0; right: 0; + text-align: center; + color: rgba(255,255,255,.75); + font-size: .9rem; + z-index: 2; + pointer-events: none; +} +/* result overlay */ +.scan-result { + flex-direction: column; + align-items: center; + gap: 1.25rem; + padding: 2rem; + text-align: center; +} +.scan-result-check { + width: 72px; + height: 72px; + border-radius: 50%; + background: linear-gradient(135deg, var(--teal), var(--teal-dark)); + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + color: #fff; +} +.scan-result-label { + color: rgba(255,255,255,.75); + font-size: 1rem; +} +.scan-result-actions { + display: flex; + flex-direction: column; + gap: .75rem; + width: 100%; + max-width: 260px; +} diff --git a/src/main/resources/static/images/favicon.png b/src/main/resources/static/images/favicon.png new file mode 100644 index 0000000..554126f Binary files /dev/null and b/src/main/resources/static/images/favicon.png differ diff --git a/src/main/resources/static/images/logo.png b/src/main/resources/static/images/logo.png new file mode 100644 index 0000000..1d82b0e Binary files /dev/null and b/src/main/resources/static/images/logo.png differ diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..7086446 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,1667 @@ + + + + + + LibreDeck – Spielkarten aus deinen Playlists + + + + + + + +
+
+
+

Kostenlos · Kein Account nötig

+

Spielkarten aus deinen
Lieblings‑Playlists

+

+ Füge einen Playlist-Link ein und lade ein druckfertiges PDF herunter. + Jede Karte hat vorne einen QR-Code zum Abspielen und hinten + Künstler, Titel und Erscheinungsjahr. +

+ Jetzt Karten erstellen +
+ + +
+
+ + +
+
+ +
+ + Spotify +
+ +
+
+ + +
+
+

So funktioniert es

+ +
+ +
+
+
+ + + +
+

Mit Spotify verbinden

+

Klicke auf „Mit Spotify verbinden" und melde dich einmalig pro Sitzung an.

+
+ +
+
+ + + +
+

Playlist auswählen

+

Deine Playlists erscheinen direkt zur Auswahl – einfach anklicken und PDF wird erstellt.

+
+ +
+
+ + + +
+

Ausdrucken & spielen

+

PDF doppelseitig drucken, Karten ausschneiden – fertig.

+
+
+ + + + + + + + + + + + +
+
+
+ + +
+
+
+

Was drauf steht

+
    +
  • + V +
    + Vorderseite +

    QR-Code, der direkt zum Track auf Deezer führt – einfach scannen und Musik läuft.

    +
    +
  • +
  • + R +
    + Rückseite +

    Erscheinungsjahr, Künstlername und Titel – alles was du zum Raten und Einordnen brauchst.

    +
    +
  • +
  • + +
    + Druckfertig +

    35 Karten à 40 × 40 mm auf einer A4-Seite

    +
    +
  • +
+
+ +
+
+ + +
+
+

PDF erstellen

+

Wähle deinen Streaming-Dienst und starte direkt.

+ +
+ +
+ + +
+
+

Verbinde dich einmalig mit Spotify – dann kannst du deine Playlists direkt auswählen.

+ Mit Spotify verbinden +
+ +
+ + + + + + + + + + + + + +
+ + +
+
+ + + +
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/playlists.html b/src/main/resources/templates/playlists.html new file mode 100644 index 0000000..a4f31d0 --- /dev/null +++ b/src/main/resources/templates/playlists.html @@ -0,0 +1,62 @@ + + + + + + Playlists – libredeck + + + +
+
+

libredeck

+
+ + Abmelden +
+
+ +
+

Deine Playlists

+

Wähle eine Playlist aus, um ein druckfertiges PDF zu generieren.

+ +
+
+ Cover +
+ +
+

Playlist Name

+

+
+ + + PDF generieren + +
+
+ +
+

Keine Playlists gefunden.

+
+
+
+ + + + + +