From 292de62198d6709ac2c9017798f405297e92fac6 Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 15 Jul 2026 10:34:32 +0200 Subject: [PATCH] DiscDrop: Quarkus + htmx + daisyUI app to follow MusicBrainz artists and track release groups (web feed + RSS with cover art) --- .gitignore | 25 ++ .mvn/wrapper/maven-wrapper.properties | 3 + README.md | 29 ++ mvnw | 295 ++++++++++++++++++ mvnw.cmd | 189 +++++++++++ pom.xml | 157 ++++++++++ .../java/io/discdrop/StartupValidator.java | 21 ++ .../io/discdrop/mbz/MusicBrainzClient.java | 31 ++ .../mbz/MusicBrainzHeadersFactory.java | 30 ++ .../io/discdrop/mbz/MusicBrainzService.java | 47 +++ .../discdrop/mbz/dto/ArtistSearchResult.java | 23 ++ .../mbz/dto/ReleaseGroupBrowseResult.java | 39 +++ .../io/discdrop/persistence/AppSetting.java | 36 +++ .../persistence/ArtistTypeSetting.java | 36 +++ .../discdrop/persistence/FollowedArtist.java | 42 +++ .../persistence/ReleaseGroupEntity.java | 48 +++ .../persistence/ReleaseGroupRepository.java | 85 +++++ .../io/discdrop/resource/ArtistResource.java | 116 +++++++ .../io/discdrop/resource/PageResource.java | 58 ++++ .../io/discdrop/resource/RssResource.java | 70 +++++ .../io/discdrop/resource/SearchResource.java | 45 +++ .../discdrop/resource/SettingsResource.java | 61 ++++ .../io/discdrop/service/ArtistService.java | 104 ++++++ .../java/io/discdrop/service/FeedService.java | 40 +++ .../io/discdrop/service/SettingsService.java | 52 +++ .../java/io/discdrop/service/SyncService.java | 99 ++++++ src/main/resources/application.properties | 22 ++ src/main/resources/static/css/app.css | 3 + src/main/resources/static/img/favicon.svg | 8 + src/main/resources/static/img/logo.svg | 9 + src/main/resources/static/img/no-cover.svg | 6 + src/main/resources/static/js/app.js | 72 +++++ src/main/resources/templates/artists.html | 46 +++ .../templates/fragments/artist-row.html | 36 +++ .../templates/fragments/feed-list.html | 18 ++ .../templates/fragments/feed-row.html | 22 ++ .../templates/fragments/search-dropdown.html | 39 +++ .../templates/fragments/settings-panel.html | 39 +++ src/main/resources/templates/index.html | 73 +++++ src/main/resources/templates/rss.xml | 21 ++ 40 files changed, 2195 insertions(+) create mode 100644 .gitignore create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 README.md create mode 100755 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/io/discdrop/StartupValidator.java create mode 100644 src/main/java/io/discdrop/mbz/MusicBrainzClient.java create mode 100644 src/main/java/io/discdrop/mbz/MusicBrainzHeadersFactory.java create mode 100644 src/main/java/io/discdrop/mbz/MusicBrainzService.java create mode 100644 src/main/java/io/discdrop/mbz/dto/ArtistSearchResult.java create mode 100644 src/main/java/io/discdrop/mbz/dto/ReleaseGroupBrowseResult.java create mode 100644 src/main/java/io/discdrop/persistence/AppSetting.java create mode 100644 src/main/java/io/discdrop/persistence/ArtistTypeSetting.java create mode 100644 src/main/java/io/discdrop/persistence/FollowedArtist.java create mode 100644 src/main/java/io/discdrop/persistence/ReleaseGroupEntity.java create mode 100644 src/main/java/io/discdrop/persistence/ReleaseGroupRepository.java create mode 100644 src/main/java/io/discdrop/resource/ArtistResource.java create mode 100644 src/main/java/io/discdrop/resource/PageResource.java create mode 100644 src/main/java/io/discdrop/resource/RssResource.java create mode 100644 src/main/java/io/discdrop/resource/SearchResource.java create mode 100644 src/main/java/io/discdrop/resource/SettingsResource.java create mode 100644 src/main/java/io/discdrop/service/ArtistService.java create mode 100644 src/main/java/io/discdrop/service/FeedService.java create mode 100644 src/main/java/io/discdrop/service/SettingsService.java create mode 100644 src/main/java/io/discdrop/service/SyncService.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/static/css/app.css create mode 100644 src/main/resources/static/img/favicon.svg create mode 100644 src/main/resources/static/img/logo.svg create mode 100644 src/main/resources/static/img/no-cover.svg create mode 100644 src/main/resources/static/js/app.js create mode 100644 src/main/resources/templates/artists.html create mode 100644 src/main/resources/templates/fragments/artist-row.html create mode 100644 src/main/resources/templates/fragments/feed-list.html create mode 100644 src/main/resources/templates/fragments/feed-row.html create mode 100644 src/main/resources/templates/fragments/search-dropdown.html create mode 100644 src/main/resources/templates/fragments/settings-panel.html create mode 100644 src/main/resources/templates/index.html create mode 100644 src/main/resources/templates/rss.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fdf2f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Maven +target/ +*.jar +*.war + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.project +.classpath +nbactions.xml +nb-configuration.xml + +# Quarkus dev +.mvn/wrapper/maven-wrapper.jar + +# H2 data +data/ +*.mv.db +*.trace.db + +# OS +.DS_Store diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..9edb0f9 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=http://nexus.services.odigeo.com/nexus/content/groups/public/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..14a9b8f --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# DiscDrop + +Follow MusicBrainz artists and track their **release groups** (not individual releases, to avoid duplicate vinyl/CD editions). Shows a combined web feed plus an RSS feed of new release groups, ordered by first-release-date. + +Built with Java 21 + Quarkus, htmx, and daisyUI. + +## Run (dev) + +```bash +./mvnw quarkus:dev +``` + +App at http://localhost:8080 — RSS at http://localhost:8080/rss + +## Configuration (`application.properties`) + +| Property | Default | Notes | +|---|---|---| +| `discdrop.mbz.user-agent` | `DiscDrop/1.0 (david.alvarez.81@gmail.com)` | **Required.** MusicBrainz needs a meaningful contact string. Override per environment. | +| `quarkus.rest-client."musicbrainz".url` | `https://musicbrainz.org/ws/2` | MBZ API base. | +| `discdrop.mbz.rate-limit-ms` | `1000` | Enforced gap between MBZ calls (≤1 req/s). | +| `discdrop.feed.page-size` | `25` | Feed page size / load-more batch. | +| `discdrop.rss.item-count` | `50` | RSS items. | + +The H2 file database lives in `./data/discdrop.mv.db` (gitignored). + +## Architecture + +See `../discdrop-plan.md` for the full design. diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + 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" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9414962 --- /dev/null +++ b/pom.xml @@ -0,0 +1,157 @@ + + + 4.0.0 + + io.discdrop + discdrop + 1.0.0-SNAPSHOT + DiscDrop + Follow MusicBrainz artists and track their release groups + + + 3.13.0 + 21 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.12.1 + true + 3.2.5 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-resteasy-reactive-qute + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkus + quarkus-rest-client + + + io.quarkus + quarkus-rest-client-jackson + + + io.quarkus + quarkus-hibernate-orm + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-h2 + + + io.quarkus + quarkus-scheduler + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-mockito + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin} + + + -parameters + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + native + + + + + + maven-failsafe-plugin + + ${skipITs} + + + + + + native + + + + diff --git a/src/main/java/io/discdrop/StartupValidator.java b/src/main/java/io/discdrop/StartupValidator.java new file mode 100644 index 0000000..c79a60a --- /dev/null +++ b/src/main/java/io/discdrop/StartupValidator.java @@ -0,0 +1,21 @@ +package io.discdrop; + +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import org.eclipse.microprofile.config.ConfigProvider; + +@ApplicationScoped +public class StartupValidator { + + void onStart(@Observes StartupEvent event) { + String ua = ConfigProvider.getConfig() + .getOptionalValue("discdrop.mbz.user-agent", String.class) + .orElse(null); + if (ua == null || ua.isBlank()) { + throw new IllegalStateException( + "Missing required configuration 'discdrop.mbz.user-agent'. " + + "MusicBrainz requires a meaningful User-Agent contact string."); + } + } +} diff --git a/src/main/java/io/discdrop/mbz/MusicBrainzClient.java b/src/main/java/io/discdrop/mbz/MusicBrainzClient.java new file mode 100644 index 0000000..8d39766 --- /dev/null +++ b/src/main/java/io/discdrop/mbz/MusicBrainzClient.java @@ -0,0 +1,31 @@ +package io.discdrop.mbz; + +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.mbz.dto.ReleaseGroupBrowseResult; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@Path("/") +@RegisterRestClient(configKey = "musicbrainz") +@RegisterClientHeaders(MusicBrainzHeadersFactory.class) +public interface MusicBrainzClient { + + @GET + @Path("/artist") + ArtistSearchResult searchArtists(@QueryParam("query") String q, + @QueryParam("limit") int limit, + @QueryParam("fmt") String fmt); + + @GET + @Path("/release-group") + ReleaseGroupBrowseResult browseReleaseGroups(@QueryParam("artist") String mbid, + @QueryParam("type") String types, + @QueryParam("limit") int limit, + @QueryParam("offset") int offset, + @QueryParam("inc") String inc, + @QueryParam("fmt") String fmt, + @QueryParam("release-group-status") String status); +} diff --git a/src/main/java/io/discdrop/mbz/MusicBrainzHeadersFactory.java b/src/main/java/io/discdrop/mbz/MusicBrainzHeadersFactory.java new file mode 100644 index 0000000..587f65f --- /dev/null +++ b/src/main/java/io/discdrop/mbz/MusicBrainzHeadersFactory.java @@ -0,0 +1,30 @@ +package io.discdrop.mbz; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.ext.Provider; +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory; + +@ApplicationScoped +@Provider +public class MusicBrainzHeadersFactory implements ClientHeadersFactory { + + private static final String USER_AGENT_KEY = "discdrop.mbz.user-agent"; + + @Override + public MultivaluedMap update(MultivaluedMap incoming, + MultivaluedMap outgoing) { + String ua = ConfigProvider.getConfig() + .getOptionalValue(USER_AGENT_KEY, String.class) + .filter(s -> s != null && !s.isBlank()) + .orElse(null); + if (ua == null) { + throw new IllegalStateException( + "Missing required configuration 'discdrop.mbz.user-agent'. " + + "MusicBrainz requires a meaningful User-Agent contact string."); + } + outgoing.putSingle("User-Agent", ua); + return outgoing; + } +} diff --git a/src/main/java/io/discdrop/mbz/MusicBrainzService.java b/src/main/java/io/discdrop/mbz/MusicBrainzService.java new file mode 100644 index 0000000..0726079 --- /dev/null +++ b/src/main/java/io/discdrop/mbz/MusicBrainzService.java @@ -0,0 +1,47 @@ +package io.discdrop.mbz; + +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.mbz.dto.ReleaseGroupBrowseResult; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import java.util.List; + +@ApplicationScoped +public class MusicBrainzService { + + @Inject + @RestClient + MusicBrainzClient client; + + @ConfigProperty(name = "discdrop.mbz.rate-limit-ms", defaultValue = "1000") + long rateLimitMs; + + private volatile long lastCallMillis = 0L; + + public synchronized void acquire() { + long elapsed = System.currentTimeMillis() - lastCallMillis; + if (elapsed < rateLimitMs) { + try { + Thread.sleep(rateLimitMs - elapsed); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + lastCallMillis = System.currentTimeMillis(); + } + + public List searchArtists(String query, int limit) { + acquire(); + ArtistSearchResult result = client.searchArtists(query, limit, "json"); + return result != null && result.artists != null ? result.artists : List.of(); + } + + public ReleaseGroupBrowseResult browseReleaseGroups(String mbid, String types, int limit, int offset) { + acquire(); + return client.browseReleaseGroups( + mbid, types, limit, offset, "artist-credits", "json", "website-default"); + } +} diff --git a/src/main/java/io/discdrop/mbz/dto/ArtistSearchResult.java b/src/main/java/io/discdrop/mbz/dto/ArtistSearchResult.java new file mode 100644 index 0000000..ca3ec89 --- /dev/null +++ b/src/main/java/io/discdrop/mbz/dto/ArtistSearchResult.java @@ -0,0 +1,23 @@ +package io.discdrop.mbz.dto; + +import java.util.List; + +public class ArtistSearchResult { + public int count; + public int offset; + public List artists; + + public static class ArtistDto { + public String id; + public String name; + public String disambiguation; + public String type; + public String country; + public String score; + public Area area; + + public static class Area { + public String name; + } + } +} diff --git a/src/main/java/io/discdrop/mbz/dto/ReleaseGroupBrowseResult.java b/src/main/java/io/discdrop/mbz/dto/ReleaseGroupBrowseResult.java new file mode 100644 index 0000000..ef3d18c --- /dev/null +++ b/src/main/java/io/discdrop/mbz/dto/ReleaseGroupBrowseResult.java @@ -0,0 +1,39 @@ +package io.discdrop.mbz.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public class ReleaseGroupBrowseResult { + @JsonProperty("release-group-count") + public int count; + + @JsonProperty("release-group-offset") + public int offset; + + @JsonProperty("release-groups") + public List releaseGroups; + + public static class ReleaseGroupDto { + public String id; + public String title; + @JsonProperty("first-release-date") + public String firstReleaseDate; + @JsonProperty("primary-type") + public String primaryType; + @JsonProperty("secondary-types") + public List secondaryTypes; + @JsonProperty("artist-credit") + public List artistCredit; + + public static class ArtistCredit { + public String name; + public String joinphrase; + public Artist artist; + + public static class Artist { + public String id; + public String name; + } + } + } +} diff --git a/src/main/java/io/discdrop/persistence/AppSetting.java b/src/main/java/io/discdrop/persistence/AppSetting.java new file mode 100644 index 0000000..ee8a5a9 --- /dev/null +++ b/src/main/java/io/discdrop/persistence/AppSetting.java @@ -0,0 +1,36 @@ +package io.discdrop.persistence; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table +public class AppSetting extends PanacheEntity { + + @Column(name = "setting_key", nullable = false, unique = true) + public String key; + + @Column(name = "setting_value", nullable = false, columnDefinition = "TEXT") + public String value; + + public static AppSetting find(String key) { + return find("key", key).firstResult(); + } + + public static String get(String key, String defaultValue) { + AppSetting s = find(key); + return s != null ? s.value : defaultValue; + } + + public static void set(String key, String value) { + AppSetting s = find(key); + if (s == null) { + s = new AppSetting(); + s.key = key; + s.persist(); + } + s.value = value; + } +} diff --git a/src/main/java/io/discdrop/persistence/ArtistTypeSetting.java b/src/main/java/io/discdrop/persistence/ArtistTypeSetting.java new file mode 100644 index 0000000..0bb1d2c --- /dev/null +++ b/src/main/java/io/discdrop/persistence/ArtistTypeSetting.java @@ -0,0 +1,36 @@ +package io.discdrop.persistence; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(uniqueConstraints = @UniqueConstraint( + name = "uk_artist_primary_type", columnNames = { "artist_id", "primary_type" } +)) +public class ArtistTypeSetting extends PanacheEntity { + + @ManyToOne(optional = false) + @JoinColumn(name = "artist_id", nullable = false, + foreignKey = @ForeignKey(name = "fk_type_setting_artist")) + public FollowedArtist artist; + + @Column(name = "primary_type", nullable = false) + public String primaryType; + + @Column(nullable = false) + public boolean enabled; + + public static long deleteByArtist(FollowedArtist artist) { + return delete("artist", artist); + } + + public static java.util.List findByArtist(FollowedArtist artist) { + return list("artist", artist); + } +} diff --git a/src/main/java/io/discdrop/persistence/FollowedArtist.java b/src/main/java/io/discdrop/persistence/FollowedArtist.java new file mode 100644 index 0000000..94ea917 --- /dev/null +++ b/src/main/java/io/discdrop/persistence/FollowedArtist.java @@ -0,0 +1,42 @@ +package io.discdrop.persistence; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table +public class FollowedArtist extends PanacheEntity { + + @Column(nullable = false, unique = true) + public String mbid; + + @Column(nullable = false) + public String name; + + public String sortName; + + public String disambiguation; + + public String areaName; + + public String type; + + public String country; + + @Column(nullable = false) + public Instant followedAt; + + public Instant lastSyncedAt; + + public static FollowedArtist findByMbid(String mbid) { + return find("mbid", mbid).firstResult(); + } + + public static boolean existsByMbid(String mbid) { + return count("mbid", mbid) > 0; + } +} diff --git a/src/main/java/io/discdrop/persistence/ReleaseGroupEntity.java b/src/main/java/io/discdrop/persistence/ReleaseGroupEntity.java new file mode 100644 index 0000000..7078575 --- /dev/null +++ b/src/main/java/io/discdrop/persistence/ReleaseGroupEntity.java @@ -0,0 +1,48 @@ +package io.discdrop.persistence; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table +public class ReleaseGroupEntity extends PanacheEntity { + + @Column(nullable = false, unique = true) + public String mbid; + + @ManyToOne(optional = false) + @JoinColumn(name = "artist_id", nullable = false, + foreignKey = @ForeignKey(name = "fk_rg_artist")) + public FollowedArtist artist; + + @Column(nullable = false) + public String title; + + public LocalDate firstReleaseDate; + + public String firstReleaseDateRaw; + + public String primaryType; + + public String secondaryTypes; + + @Column(nullable = false) + public String mbzUrl; + + public Instant discoveredAt; + + public static ReleaseGroupEntity findByMbid(String mbid) { + return find("mbid", mbid).firstResult(); + } + + public static long deleteByArtist(FollowedArtist artist) { + return delete("artist", artist); + } +} diff --git a/src/main/java/io/discdrop/persistence/ReleaseGroupRepository.java b/src/main/java/io/discdrop/persistence/ReleaseGroupRepository.java new file mode 100644 index 0000000..d043913 --- /dev/null +++ b/src/main/java/io/discdrop/persistence/ReleaseGroupRepository.java @@ -0,0 +1,85 @@ +package io.discdrop.persistence; + +import io.discdrop.mbz.dto.ReleaseGroupBrowseResult; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; + +@ApplicationScoped +public class ReleaseGroupRepository { + + @Transactional + public FollowedArtist findArtist(String mbid) { + return FollowedArtist.findByMbid(mbid); + } + + @Transactional + public void upsertPage(String mbid, List dtos) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist == null) { + return; + } + for (ReleaseGroupBrowseResult.ReleaseGroupDto rg : dtos) { + upsertOne(artist, rg); + } + } + + @Transactional + public void markSynced(String mbid) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist != null) { + artist.lastSyncedAt = Instant.now(); + } + } + + @Transactional + public void deleteByArtistMbid(String mbid) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist != null) { + ReleaseGroupEntity.deleteByArtist(artist); + } + } + + private void upsertOne(FollowedArtist artist, ReleaseGroupBrowseResult.ReleaseGroupDto rg) { + ReleaseGroupEntity entity = ReleaseGroupEntity.findByMbid(rg.id); + boolean isNew = entity == null; + if (isNew) { + entity = new ReleaseGroupEntity(); + entity.mbid = rg.id; + entity.artist = artist; + entity.discoveredAt = Instant.now(); + } + entity.title = rg.title; + entity.firstReleaseDateRaw = rg.firstReleaseDate; + entity.firstReleaseDate = parseDate(rg.firstReleaseDate); + entity.primaryType = rg.primaryType; + entity.secondaryTypes = rg.secondaryTypes != null ? String.join(", ", rg.secondaryTypes) : null; + entity.mbzUrl = "https://musicbrainz.org/release-group/" + rg.id; + if (isNew) { + entity.persist(); + } + } + + private LocalDate parseDate(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + if (raw.length() >= 10) { + return LocalDate.parse(raw.substring(0, 10), DateTimeFormatter.ISO_LOCAL_DATE); + } else if (raw.length() >= 7) { + return LocalDate.parse(raw + "-01", DateTimeFormatter.ISO_LOCAL_DATE); + } else if (raw.length() >= 4) { + return LocalDate.parse(raw + "-01-01", DateTimeFormatter.ISO_LOCAL_DATE); + } + } catch (DateTimeParseException e) { + return null; + } + return null; + } +} diff --git a/src/main/java/io/discdrop/resource/ArtistResource.java b/src/main/java/io/discdrop/resource/ArtistResource.java new file mode 100644 index 0000000..148a17c --- /dev/null +++ b/src/main/java/io/discdrop/resource/ArtistResource.java @@ -0,0 +1,116 @@ +package io.discdrop.resource; + +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.persistence.ArtistTypeSetting; +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.persistence.ReleaseGroupEntity; +import io.discdrop.service.ArtistService; +import io.discdrop.service.FeedService; +import io.discdrop.service.SyncService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +import java.util.List; + +@Path("/artists") +public class ArtistResource { + + @Inject + ArtistService artistService; + + @Inject + SyncService syncService; + + @Inject + FeedService feedService; + + @Inject + @Location("fragments/feed-list.html") + Template fragments_feed_list; + + @Inject + @Location("fragments/artist-row.html") + Template fragments_artist_row; + + @Inject + Template artists; + + @POST + @Path("/follow") + public TemplateInstance follow(@FormParam("mbid") String mbid, + @FormParam("name") String name, + @FormParam("disambiguation") String disambiguation, + @FormParam("area") String area, + @FormParam("type") String type, + @FormParam("country") String country) { + ArtistSearchResult.ArtistDto dto = new ArtistSearchResult.ArtistDto(); + dto.id = mbid; + dto.name = name; + dto.disambiguation = disambiguation; + dto.type = type; + dto.country = country; + if (area != null && !area.isBlank()) { + dto.area = new ArtistSearchResult.ArtistDto.Area(); + dto.area.name = area; + } + FollowedArtist artist = artistService.follow(dto); + syncService.syncArtist(artist.mbid); + return feedFragment(0); + } + + @DELETE + @Path("/{mbid}") + public TemplateInstance unfollow(@PathParam("mbid") String mbid) { + artistService.unfollow(mbid); + return feedFragment(0); + } + + @GET + public TemplateInstance artistsPage() { + List artistsList = artistService.allFollowed(); + return artists.data("artists", artistsList); + } + + @GET + @Path("/{mbid}/row") + public TemplateInstance artistRow(@PathParam("mbid") String mbid) { + FollowedArtist artist = artistService.findArtist(mbid); + List settings = artist != null ? artistService.settingsFor(artist) : List.of(); + return fragments_artist_row.data("artist", artist) + .data("settings", settings); + } + + @POST + @Path("/{mbid}/types") + public TemplateInstance toggleType(@PathParam("mbid") String mbid, + @FormParam("primaryType") String primaryType, + @FormParam("enabled") boolean enabled) { + FollowedArtist artist = artistService.findArtist(mbid); + if (artist != null) { + artistService.setTypeEnabled(artist, primaryType, enabled); + syncService.resyncArtist(mbid); + } + return feedFragment(0); + } + + private TemplateInstance feedFragment(int offset) { + if (offset < 0) { + offset = 0; + } + List rows = feedService.feedPage(offset); + int nextOffset = offset + rows.size(); + boolean hasMore = rows.size() == feedService.pageSize(); + return fragments_feed_list.data("rows", rows) + .data("nextOffset", nextOffset) + .data("hasMore", hasMore) + .data("pageSize", feedService.pageSize()); + } +} diff --git a/src/main/java/io/discdrop/resource/PageResource.java b/src/main/java/io/discdrop/resource/PageResource.java new file mode 100644 index 0000000..686d43a --- /dev/null +++ b/src/main/java/io/discdrop/resource/PageResource.java @@ -0,0 +1,58 @@ +package io.discdrop.resource; + +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.persistence.ReleaseGroupEntity; +import io.discdrop.service.FeedService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import java.util.List; + +@Path("/") +public class PageResource { + + @Inject + FeedService feedService; + + @Inject + Template index; + + @Inject + @Location("fragments/feed-list.html") + Template fragments_feed_list; + + @Path("/feed") + @GET + @Transactional + public TemplateInstance feedFragment(@QueryParam("offset") int offset) { + if (offset < 0) { + offset = 0; + } + List rows = feedService.feedPage(offset); + int nextOffset = offset + rows.size(); + boolean hasMore = rows.size() == feedService.pageSize(); + return fragments_feed_list.data("rows", rows) + .data("nextOffset", nextOffset) + .data("hasMore", hasMore) + .data("pageSize", feedService.pageSize()); + } + + @GET + @Transactional + public TemplateInstance index() { + List rows = feedService.feedPage(0); + boolean hasMore = rows.size() == feedService.pageSize(); + return index.data("rows", rows) + .data("nextOffset", rows.size()) + .data("hasMore", hasMore) + .data("pageSize", feedService.pageSize()) + .data("followedCount", FollowedArtist.count()) + .data("feedCount", ReleaseGroupEntity.count()); + } +} diff --git a/src/main/java/io/discdrop/resource/RssResource.java b/src/main/java/io/discdrop/resource/RssResource.java new file mode 100644 index 0000000..8781fd5 --- /dev/null +++ b/src/main/java/io/discdrop/resource/RssResource.java @@ -0,0 +1,70 @@ +package io.discdrop.resource; + +import io.discdrop.persistence.ReleaseGroupEntity; +import io.discdrop.service.FeedService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Path("/rss") +public class RssResource { + + private static final DateTimeFormatter RFC822 = DateTimeFormatter.RFC_1123_DATE_TIME; + + @Inject + FeedService feedService; + + @Inject + @Location("rss.xml") + Template rss; + + @ConfigProperty(name = "discdrop.rss.item-count", defaultValue = "50") + int itemCount; + + public record RssItem(String title, String link, String guid, String pubDate, + String description, String mediaUrl) {} + + @GET + @Produces(MediaType.APPLICATION_XML) + public TemplateInstance feed() { + List entities = feedService.latest(itemCount); + List items = new ArrayList<>(entities.size()); + for (ReleaseGroupEntity e : entities) { + String pubDate = null; + if (e.firstReleaseDate != null) { + pubDate = e.firstReleaseDate.atStartOfDay(ZoneOffset.UTC).format(RFC822); + } + StringBuilder desc = new StringBuilder(); + if (e.primaryType != null) { + desc.append("Type: ").append(e.primaryType); + } + if (e.secondaryTypes != null) { + desc.append(", ").append(e.secondaryTypes); + } + desc.append("\nReleased: ").append(e.firstReleaseDateRaw != null ? e.firstReleaseDateRaw : "unknown"); + items.add(new RssItem( + e.artist.name + " – " + e.title, + e.mbzUrl, + e.mbzUrl, + pubDate, + desc.toString(), + "https://coverartarchive.org/release-group/" + e.mbid + "/front" + )); + } + return rss.data("items", items) + .data("buildDate", OffsetDateTime.now(ZoneOffset.UTC).format(RFC822)); + } +} diff --git a/src/main/java/io/discdrop/resource/SearchResource.java b/src/main/java/io/discdrop/resource/SearchResource.java new file mode 100644 index 0000000..6a2643b --- /dev/null +++ b/src/main/java/io/discdrop/resource/SearchResource.java @@ -0,0 +1,45 @@ +package io.discdrop.resource; + +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.service.ArtistService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import java.util.List; + +@Path("/search") +public class SearchResource { + + @Inject + ArtistService artistService; + + @Inject + @Location("fragments/search-dropdown.html") + Template fragments_search_dropdown; + + @Path("/artists") + @GET + public TemplateInstance searchArtists(@QueryParam("q") String q) { + List results = List.of(); + boolean tooShort = q == null || q.trim().length() < 2; + if (!tooShort) { + try { + results = artistService.search(q.trim()); + } catch (Exception e) { + results = List.of(); + } + } + List followedMbids = FollowedArtist.streamAll() + .map(a -> a.mbid) + .toList(); + return fragments_search_dropdown.data("results", results) + .data("followedMbids", followedMbids) + .data("query", q); + } +} diff --git a/src/main/java/io/discdrop/resource/SettingsResource.java b/src/main/java/io/discdrop/resource/SettingsResource.java new file mode 100644 index 0000000..6c3f0e3 --- /dev/null +++ b/src/main/java/io/discdrop/resource/SettingsResource.java @@ -0,0 +1,61 @@ +package io.discdrop.resource; + +import io.discdrop.persistence.AppSetting; +import io.discdrop.service.SettingsService; +import io.discdrop.service.SyncService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; + +import java.util.List; +import java.util.Set; + +@Path("/settings") +public class SettingsResource { + + @Inject + SettingsService settingsService; + + @Inject + SyncService syncService; + + @Inject + @Location("fragments/settings-panel.html") + Template fragments_settings_panel; + + private static final List SCHEDULE_OPTIONS = List.of(6, 12, 24); + + @GET + public TemplateInstance panel() { + return buildPanel(null); + } + + @POST + public TemplateInstance save(@FormParam("defaultPrimaryTypes") Set defaultPrimaryTypes, + @FormParam("syncScheduleHours") int syncScheduleHours) { + if (defaultPrimaryTypes == null) { + defaultPrimaryTypes = Set.of(); + } + settingsService.setDefaultPrimaryTypes(defaultPrimaryTypes); + int previous = settingsService.getSyncScheduleHours(); + settingsService.setSyncScheduleHours(syncScheduleHours); + if (previous != syncScheduleHours) { + syncService.reschedule(); + } + return buildPanel("saved"); + } + + private TemplateInstance buildPanel(String flash) { + return fragments_settings_panel + .data("defaultPrimaryTypes", settingsService.getDefaultPrimaryTypes()) + .data("syncScheduleHours", settingsService.getSyncScheduleHours()) + .data("allPrimaryTypes", SettingsService.ALL_PRIMARY_TYPES) + .data("scheduleOptions", SCHEDULE_OPTIONS) + .data("flash", flash); + } +} diff --git a/src/main/java/io/discdrop/service/ArtistService.java b/src/main/java/io/discdrop/service/ArtistService.java new file mode 100644 index 0000000..0a055a4 --- /dev/null +++ b/src/main/java/io/discdrop/service/ArtistService.java @@ -0,0 +1,104 @@ +package io.discdrop.service; + +import io.discdrop.mbz.MusicBrainzService; +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.persistence.ArtistTypeSetting; +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.persistence.ReleaseGroupEntity; +import io.quarkus.panache.common.Sort; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@ApplicationScoped +public class ArtistService { + + @Inject + SettingsService settingsService; + + @Inject + MusicBrainzService mbzService; + + @Transactional + public FollowedArtist follow(ArtistSearchResult.ArtistDto dto) { + if (FollowedArtist.existsByMbid(dto.id)) { + return FollowedArtist.findByMbid(dto.id); + } + FollowedArtist artist = new FollowedArtist(); + artist.mbid = dto.id; + artist.name = dto.name; + artist.sortName = dto.name; + artist.disambiguation = dto.disambiguation; + artist.areaName = dto.area != null ? dto.area.name : null; + artist.type = dto.type; + artist.country = dto.country; + artist.followedAt = Instant.now(); + artist.persist(); + + Set defaults = settingsService.getDefaultPrimaryTypes(); + for (String type : SettingsService.ALL_PRIMARY_TYPES) { + ArtistTypeSetting s = new ArtistTypeSetting(); + s.artist = artist; + s.primaryType = type; + s.enabled = defaults.contains(type); + s.persist(); + } + return artist; + } + + @Transactional + public void unfollow(String mbid) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist == null) { + return; + } + ArtistTypeSetting.deleteByArtist(artist); + ReleaseGroupEntity.deleteByArtist(artist); + artist.delete(); + } + + @Transactional + public void setTypeEnabled(FollowedArtist artist, String primaryType, boolean enabled) { + for (ArtistTypeSetting s : ArtistTypeSetting.findByArtist(artist)) { + if (s.primaryType.equalsIgnoreCase(primaryType)) { + s.enabled = enabled; + } + } + } + + @Transactional + public FollowedArtist findArtist(String mbid) { + return FollowedArtist.findByMbid(mbid); + } + + @Transactional + public Set enabledTypes(String mbid) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist == null) { + return Set.of(); + } + return ArtistTypeSetting.findByArtist(artist).stream() + .filter(s -> s.enabled) + .map(s -> s.primaryType) + .collect(Collectors.toSet()); + } + + @Transactional + public List allFollowed() { + return FollowedArtist.listAll(Sort.by("name")); + } + + @Transactional + public List settingsFor(FollowedArtist artist) { + return ArtistTypeSetting.findByArtist(artist); + } + + public List search(String query) { + return mbzService.searchArtists(query, 10); + } +} diff --git a/src/main/java/io/discdrop/service/FeedService.java b/src/main/java/io/discdrop/service/FeedService.java new file mode 100644 index 0000000..4573205 --- /dev/null +++ b/src/main/java/io/discdrop/service/FeedService.java @@ -0,0 +1,40 @@ +package io.discdrop.service; + +import io.discdrop.persistence.ReleaseGroupEntity; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.List; + +@ApplicationScoped +public class FeedService { + + @ConfigProperty(name = "discdrop.feed.page-size", defaultValue = "25") + int pageSize; + + private static final String ORDER_HQL = + "ORDER BY firstReleaseDate DESC NULLS LAST, discoveredAt DESC"; + + private PanacheQuery orderedQuery() { + return ReleaseGroupEntity.find(ORDER_HQL); + } + + @Transactional + public List feedPage(int offset) { + if (offset < 0) { + offset = 0; + } + return orderedQuery().range(offset, offset + pageSize - 1).list(); + } + + @Transactional + public List latest(int count) { + return orderedQuery().range(0, Math.max(0, count - 1)).list(); + } + + public int pageSize() { + return pageSize; + } +} diff --git a/src/main/java/io/discdrop/service/SettingsService.java b/src/main/java/io/discdrop/service/SettingsService.java new file mode 100644 index 0000000..5a4e38e --- /dev/null +++ b/src/main/java/io/discdrop/service/SettingsService.java @@ -0,0 +1,52 @@ +package io.discdrop.service; + +import io.discdrop.persistence.AppSetting; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@ApplicationScoped +public class SettingsService { + + public static final String KEY_DEFAULT_PRIMARY_TYPES = "defaultPrimaryTypes"; + public static final String KEY_SYNC_SCHEDULE_HOURS = "syncScheduleHours"; + + public static final List ALL_PRIMARY_TYPES = List.of("album", "single", "ep", "broadcast", "other"); + + public Set getDefaultPrimaryTypes() { + String raw = AppSetting.get(KEY_DEFAULT_PRIMARY_TYPES, "album"); + return parseTypes(raw); + } + + public void setDefaultPrimaryTypes(Set types) { + AppSetting.set(KEY_DEFAULT_PRIMARY_TYPES, String.join(",", types)); + } + + public int getSyncScheduleHours() { + String raw = AppSetting.get(KEY_SYNC_SCHEDULE_HOURS, "24"); + try { + return Integer.parseInt(raw); + } catch (NumberFormatException e) { + return 24; + } + } + + public void setSyncScheduleHours(int hours) { + AppSetting.set(KEY_SYNC_SCHEDULE_HOURS, String.valueOf(hours)); + } + + private Set parseTypes(String raw) { + if (raw == null || raw.isBlank()) { + return new LinkedHashSet<>(); + } + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(String::toLowerCase) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } +} diff --git a/src/main/java/io/discdrop/service/SyncService.java b/src/main/java/io/discdrop/service/SyncService.java new file mode 100644 index 0000000..ee2ee78 --- /dev/null +++ b/src/main/java/io/discdrop/service/SyncService.java @@ -0,0 +1,99 @@ +package io.discdrop.service; + +import io.discdrop.mbz.MusicBrainzService; +import io.discdrop.mbz.dto.ReleaseGroupBrowseResult; +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.persistence.ReleaseGroupRepository; +import io.quarkus.runtime.StartupEvent; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@ApplicationScoped +public class SyncService { + + @Inject + MusicBrainzService mbzService; + + @Inject + ReleaseGroupRepository repo; + + @Inject + ArtistService artistService; + + @Inject + SettingsService settingsService; + + private final ScheduledExecutorService executor = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "discdrop-sync"); + t.setDaemon(true); + return t; + }); + + private volatile ScheduledFuture currentTask; + + void onStart(@Observes StartupEvent event) { + reschedule(); + } + + @PreDestroy + void shutdown() { + executor.shutdownNow(); + } + + public synchronized void reschedule() { + if (currentTask != null) { + currentTask.cancel(false); + } + int hours = settingsService.getSyncScheduleHours(); + long period = TimeUnit.HOURS.toSeconds(hours); + currentTask = executor.scheduleAtFixedRate( + this::runSyncSafe, period, period, TimeUnit.SECONDS); + } + + void runSyncSafe() { + try { + runSync(); + } catch (Exception e) { + // keep the scheduler alive across failures + } + } + + public void runSync() { + for (FollowedArtist artist : artistService.allFollowed()) { + syncArtist(artist.mbid); + } + } + + public void syncArtist(String mbid) { + Set enabledTypes = artistService.enabledTypes(mbid); + String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes); + + int limit = 100; + int offset = 0; + boolean more = true; + while (more) { + ReleaseGroupBrowseResult page = mbzService.browseReleaseGroups(mbid, typeFilter, limit, offset); + if (page == null || page.releaseGroups == null || page.releaseGroups.isEmpty()) { + break; + } + repo.upsertPage(mbid, page.releaseGroups); + offset += page.releaseGroups.size(); + more = page.releaseGroups.size() == limit && offset < page.count; + } + repo.markSynced(mbid); + } + + public void resyncArtist(String mbid) { + repo.deleteByArtistMbid(mbid); + syncArtist(mbid); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..aaf5ab8 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,22 @@ +quarkus.application.name=DiscDrop +quarkus.http.port=8080 + +# H2 file DB +quarkus.datasource.db-kind=h2 +quarkus.datasource.jdbc.url=jdbc:h2:file:./data/discdrop;DB_CLOSE_DELAY=-1 +quarkus.datasource.username=sa +quarkus.datasource.password= +quarkus.hibernate-orm.database.generation=update + +# MusicBrainz REST client (configKey="musicbrainz") +quarkus.rest-client."musicbrainz".url=https://musicbrainz.org/ws/2 +# User-Agent (MBZ requires a meaningful contact string) - override per environment, never hardcode in code +discdrop.mbz.user-agent=DiscDrop/1.0 (david.alvarez.81@gmail.com) +discdrop.mbz.rate-limit-ms=1000 +discdrop.feed.page-size=25 +discdrop.rss.item-count=50 + +# Sync schedule default (hours): 6, 12, 24 (changeable at runtime via Settings) +discdrop.sync.default-schedule-hours=24 +# Default primary types for newly followed artists (comma list) +discdrop.sync.default-primary-types=album diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css new file mode 100644 index 0000000..0f2d769 --- /dev/null +++ b/src/main/resources/static/css/app.css @@ -0,0 +1,3 @@ +/* DiscDrop custom styles */ +html { scroll-behavior: smooth; } +#search-dropdown:empty { display: none; } diff --git a/src/main/resources/static/img/favicon.svg b/src/main/resources/static/img/favicon.svg new file mode 100644 index 0000000..3bbbd91 --- /dev/null +++ b/src/main/resources/static/img/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/static/img/logo.svg b/src/main/resources/static/img/logo.svg new file mode 100644 index 0000000..a807e14 --- /dev/null +++ b/src/main/resources/static/img/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/static/img/no-cover.svg b/src/main/resources/static/img/no-cover.svg new file mode 100644 index 0000000..050c205 --- /dev/null +++ b/src/main/resources/static/img/no-cover.svg @@ -0,0 +1,6 @@ + + + + + no art + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..cc5d1c9 --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,72 @@ +window.DiscDrop = (function () { + const THEMES = ['dark', 'light']; + const STORAGE_KEY = 'discdrop-theme'; + + function init() { + const saved = localStorage.getItem(STORAGE_KEY) || 'dark'; + applyTheme(saved); + } + + function applyTheme(theme) { + if (!THEMES.includes(theme)) { + theme = 'dark'; + } + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(STORAGE_KEY, theme); + const moon = document.getElementById('theme-icon-moon'); + const sun = document.getElementById('theme-icon-sun'); + if (moon && sun) { + moon.classList.toggle('hidden', theme === 'light'); + sun.classList.toggle('hidden', theme === 'dark'); + } + } + + function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + applyTheme(current === 'dark' ? 'light' : 'dark'); + } + + function clearSearch() { + const dropdown = document.getElementById('search-dropdown'); + const input = document.getElementById('search-input'); + const form = document.getElementById('search-form'); + if (dropdown) { + dropdown.innerHTML = ''; + } + if (input) { + input.value = ''; + } + if (form) { + form.reset(); + } + } + + function refreshFeed() { + if (typeof htmx !== 'undefined' && document.getElementById('feed-list')) { + htmx.ajax('GET', '/feed?offset=0', { target: '#feed-list', swap: 'innerHTML' }); + } + } + + function closeOnClickOutside(event) { + const searchWrap = document.getElementById('search-wrap'); + if (searchWrap && !searchWrap.contains(event.target)) { + const dropdown = document.getElementById('search-dropdown'); + if (dropdown) { + dropdown.innerHTML = ''; + } + } + const settingsDropdown = document.getElementById('settings-dropdown'); + if (settingsDropdown && !settingsDropdown.contains(event.target)) { + settingsDropdown.classList.remove('dropdown-open'); + } + } + + document.addEventListener('click', closeOnClickOutside); + document.addEventListener('DOMContentLoaded', init); + + return { + toggleTheme: toggleTheme, + clearSearch: clearSearch, + refreshFeed: refreshFeed + }; +})(); diff --git a/src/main/resources/templates/artists.html b/src/main/resources/templates/artists.html new file mode 100644 index 0000000..ea74d84 --- /dev/null +++ b/src/main/resources/templates/artists.html @@ -0,0 +1,46 @@ + + + + + + DiscDrop – Artists + + + + + + + + +
+ +
+ +
+

Followed Artists

+ {#if artists.empty} +
+

You're not following any artists yet.

+ Go to the feed to search and follow artists. +
+ {#else} + {#for artist in artists} +
+
+
+ {/for} + {/if} +
+ + diff --git a/src/main/resources/templates/fragments/artist-row.html b/src/main/resources/templates/fragments/artist-row.html new file mode 100644 index 0000000..aea5372 --- /dev/null +++ b/src/main/resources/templates/fragments/artist-row.html @@ -0,0 +1,36 @@ +
+
+
+
{artist.name}
+
+ {#if artist.disambiguation??}{artist.disambiguation}{#else}Artist{/if} + {#if artist.areaName??}{artist.areaName}{/if} +
+
+ +
+ +
+ {#for s in settings} + + {/for} +
+
diff --git a/src/main/resources/templates/fragments/feed-list.html b/src/main/resources/templates/fragments/feed-list.html new file mode 100644 index 0000000..8002a3c --- /dev/null +++ b/src/main/resources/templates/fragments/feed-list.html @@ -0,0 +1,18 @@ +{#for row in rows} +{#include fragments/feed-row.html row=row /} +{/for} +{#if hasMore} + +{/if} +{#if !hasMore && rows.empty} +
+

No releases yet.

+

Search for an artist above and click Follow to start tracking their release groups.

+
+{/if} diff --git a/src/main/resources/templates/fragments/feed-row.html b/src/main/resources/templates/fragments/feed-row.html new file mode 100644 index 0000000..2af9ba3 --- /dev/null +++ b/src/main/resources/templates/fragments/feed-row.html @@ -0,0 +1,22 @@ +
+ cover + +
+
+ {row.artist.name} + {#if row.primaryType??}{row.primaryType}{/if} + {#if row.secondaryTypes??}{row.secondaryTypes}{/if} +
+ + {row.title} + +
+ +
+ {#if row.firstReleaseDateRaw??}{row.firstReleaseDateRaw}{#else}—{/if} +
+
diff --git a/src/main/resources/templates/fragments/search-dropdown.html b/src/main/resources/templates/fragments/search-dropdown.html new file mode 100644 index 0000000..87bbd76 --- /dev/null +++ b/src/main/resources/templates/fragments/search-dropdown.html @@ -0,0 +1,39 @@ +{#if results.empty} +{#if query?? && query.length() >= 2} +
No artists found for "{query}".
+{/if} +{/if} +{#for a in results} +
+
+
{a.name}
+
+ {#if a.disambiguation??}{a.disambiguation}{#else}Artist{/if} + {#if a.area?? && a.area.name??}{a.area.name}{/if} +
+
+ {#if followedMbids.contains(a.id)} +
+ +
+ {#else} +
+ + + + + + {#if a.area?? && a.area.name??} + + {/if} + +
+ {/if} +
+{/for} diff --git a/src/main/resources/templates/fragments/settings-panel.html b/src/main/resources/templates/fragments/settings-panel.html new file mode 100644 index 0000000..b64b69e --- /dev/null +++ b/src/main/resources/templates/fragments/settings-panel.html @@ -0,0 +1,39 @@ +
+

Settings

+ + {#if flash == "saved"} +
Saved
+ {/if} + +
+
+
Default primary types for new artists
+
+ {#for t in allPrimaryTypes} + + {/for} +
+
+ +
+
Feed update schedule
+
+ {#for h in scheduleOptions} + + {/for} +
+
+ + +
+
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..ce18d1e --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,73 @@ + + + + + + DiscDrop + + + + + + + + + +
+ +
+ +
+
+
+ {#include fragments/feed-list.html rows=rows nextOffset=nextOffset hasMore=hasMore pageSize=pageSize /} +
+
+
+ + diff --git a/src/main/resources/templates/rss.xml b/src/main/resources/templates/rss.xml new file mode 100644 index 0000000..e8927f4 --- /dev/null +++ b/src/main/resources/templates/rss.xml @@ -0,0 +1,21 @@ + + + + DiscDrop – New Releases + http://localhost:8080/ + Release groups from the MusicBrainz artists you follow on DiscDrop. + {buildDate} + {#for item in items} + + {item.title} + {item.link} + {item.guid} + {#if item.pubDate??} + {item.pubDate} + {/if} + + + + {/for} + +