Compare commits

..
18 Commits
Author SHA1 Message Date
droideparanoico d4ad3b9d75 Future releases filter settings for feed and RSS
Build & Deploy / build-and-deploy (push) Successful in 7s
2026-07-27 16:42:53 +02:00
droideparanoico 9e3bd5e916 Added LICENSE
Build & Deploy / build-and-deploy (push) Successful in 4s
2026-07-27 11:17:35 +02:00
droideparanoico b17a794d4f github publish image workflow
Build & Deploy / build-and-deploy (push) Successful in 4s
2026-07-27 11:07:50 +02:00
droideparanoico 846e27b95f README and docker compose
Build & Deploy / build-and-deploy (push) Successful in 7s
2026-07-27 10:59:20 +02:00
droideparanoico 508bf77b6d Configurable user-agent via env var
Build & Deploy / build-and-deploy (push) Successful in 5s
Required user-agent env var
2026-07-27 10:09:25 +02:00
droideparanoico bc0f516bf3 Dockerfile user 2026-07-16 09:32:49 +02:00
droideparanoico 7f107420a8 Gitea workflow 2026-07-16 09:21:23 +02:00
droideparanoico 2434d7301d Dockerfile 2026-07-16 09:17:27 +02:00
droideparanoico 406d987e94 Unfollow via hx-swap=delete 2026-07-15 16:35:05 +02:00
droideparanoico 8f6e891bc6 Unfollow returns 204 2026-07-15 16:30:07 +02:00
droideparanoico 9019ead439 Atomic swap on type toggle 2026-07-15 16:23:45 +02:00
droideparanoico 827e87a8a8 Sync spinner on populated feed 2026-07-15 12:58:06 +02:00
droideparanoico 4ef83cd701 Sync loading indicators 2026-07-15 12:53:06 +02:00
droideparanoico 03e5b2810c Square cover art and narrower feed 2026-07-15 11:50:46 +02:00
droideparanoico 11aba60109 Type toggle targets artist row 2026-07-15 11:10:57 +02:00
droideparanoico 4236f7b7f7 Feed restyle with RSS button 2026-07-15 11:05:53 +02:00
droideparanoico d7d68b9ab0 Static path, search dropdown, async follow 2026-07-15 10:46:22 +02:00
droideparanoico 5861da89b3 Initial commit 2026-07-15 10:34:32 +02:00
35 changed files with 507 additions and 585 deletions
+7
View File
@@ -0,0 +1,7 @@
data/
target/
.git/
.gitignore
README.md
*.md
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
# Required: MusicBrainz User-Agent (must be a meaningful contact string)
MBZ_USER_AGENT=DiscDrop/1.0 ([email protected])
+29
View File
@@ -0,0 +1,29 @@
# This workflow builds DiscDrop whenever code is pushed to main.
# The runner has Docker socket access so it can manage containers on the host.
# See: https://docs.gitea.com/usage/actions
name: Build & Deploy
run-name: Build ${{ gitea.sha.substring(0, 7) }}
on:
push:
branches: [ main ]
env:
IMAGE_NAME: discdrop
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
- name: Clean up old images
run: docker image prune -f --filter "until=24h"
+45
View File
@@ -0,0 +1,45 @@
name: Publish Docker image
on:
release:
types: [published]
jobs:
push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Extract tags
id: meta
uses: docker/metadata-action@v5
with:
images: droideparanoico/discdrop
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+5
View File
@@ -2,6 +2,8 @@
target/
*.jar
*.war
mvnw
mvnw.cmd
# IDE
.idea/
@@ -21,5 +23,8 @@ data/
*.mv.db
*.trace.db
# Environment (personal config)
.env
# OS
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
# DiscDrop — Quarkus multi-stage build
# ── Stage 1: Build ──────────────────────────────────────
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml ./
COPY src ./src
RUN mvn package -DskipTests
# ── Stage 2: Runtime ────────────────────────────────────
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /build/target/quarkus-app/ /app/
VOLUME /data
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=20", "-jar", "quarkus-run.jar"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 droideparanoico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80 -14
View File
@@ -1,29 +1,95 @@
# DiscDrop
# 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.
Drop the needle on every new release from the artists you follow. DiscDrop tracks MusicBrainz **release groups** and presents them as a combined web feed plus RSS.
Built with Java 21 + Quarkus, htmx, and daisyUI.
## Run (dev)
### Feed view
![feed](images/feed.png "Feed")
## Features
### 🔍 Search & follow
Search MusicBrainz by artist name with autocomplete — see disambiguation and area at a glance. Follow any artist with one click; the feed updates immediately via htmx without a full page reload.
### 📡 Combined feed
Every release group from every followed artist, sorted by first-release date. Cover art is fetched lazily from the Cover Art Archive — no extra API calls during sync. Load more pagination keeps the page snappy.
Each row shows cover art thumbnail, artist name, release title, type badges (album / single / EP / …), and release date with an external link to MusicBrainz.
### 🎚️ Per-artist type toggles
Control which release types appear in your feed per artist — toggle `album`, `single`, `ep`, `broadcast`, and `other` independently. Changes trigger a re-sync and refresh the feed in-place.
### ⏱️ Scheduled sync
A background job refreshes release groups on a configurable schedule (6 / 12 / 24 hours). Follow a new artist and it syncs immediately, so the feed populates without waiting.
### 📰 RSS feed
A global RSS 2.0 feed with Media RSS cover art — subscribe from your reader of choice. Discoverable via `<link rel="alternate">` from the app root.
### 📅 Future release filter
Hide unreleased releases from the feed, the RSS feed, or both — independently configurable from the settings panel. Releases without a date are always shown.
### 🌗 Theme switching
Built-in light/dark theme toggle, persisted in `localStorage`.
## Quick Start
### With Docker Compose (easiest)
```bash
# 1. Configure your MusicBrainz User-Agent
cp .env.example .env
# Edit .env with your contact email
# 2. Start
docker compose up -d
```
Open http://localhost:8080 — RSS at http://localhost:8080/rss
### With Docker
```bash
docker run -p 8080:8080 \
-v $(pwd)/data:/app/data \
-e MBZ_USER_AGENT="DiscDrop/1.0 ([email protected])" \
droideparanoico/discdrop
```
> **`MBZ_USER_AGENT` is required.** MusicBrainz blocks clients without a meaningful `User-Agent`. Set it to your contact email.
### Development
```bash
# Terminal — Quarkus dev server
./mvnw quarkus:dev
```
App at http://localhost:8080 — RSS at http://localhost:8080/rss
Open http://localhost:8080
## Configuration (`application.properties`)
## Configuration
| Property | Default | Notes |
|---|---|---|
| `discdrop.mbz.user-agent` | `DiscDrop/1.0 ([email protected])` | **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. |
All settings are in `application.properties`. Key values can be overridden via environment variables:
The H2 file database lives in `./data/discdrop.mv.db` (gitignored).
| Property | Env var | Default | Notes |
|---|---|---|---|
| `discdrop.mbz.user-agent` | `MBZ_USER_AGENT` | *(required, no default)* | Contact string for MusicBrainz |
| `quarkus.rest-client."musicbrainz".url` | — | `https://musicbrainz.org/ws/2` | MBZ API base |
| `discdrop.mbz.rate-limit-ms` | — | `1000` | Gap between MBZ calls (≤1 req/s) |
| `discdrop.feed.page-size` | — | `25` | Feed page / load-more batch |
| `discdrop.rss.item-count` | — | `50` | RSS item count |
| `discdrop.sync.default-schedule-hours` | — | `24` | Default sync interval |
| `discdrop.sync.default-primary-types` | — | `album` | Default types for new artists |
| `hideFutureFeed` | — | `false` | Hide unreleased from web feed |
| `hideFutureRss` | — | `false` | Hide unreleased from RSS feed |
The H2 file database lives in `./data/discdrop.mv.db`
## Architecture
See `../discdrop-plan.md` for the full design.
- **Backend**: Quarkus (Java 21, JAX-RS, Hibernate ORM with Panache) — H2 file database, zero external services
- **Frontend**: Server-rendered Qute templates + htmx + daisyUI (Tailwind) — no SPA framework
- **MusicBrainz**: REST Client (MicroProfile) with rate-limited access, externalized `User-Agent`
- **Sync**: Quarkus Scheduler — configurable cadence, immediate sync on follow
- **RSS**: RSS 2.0 with Media RSS cover art, Qute XML template
- **Cover art**: Deterministic URLs from Cover Art Archive — fetched lazily by the browser, no extra sync overhead
+12
View File
@@ -0,0 +1,12 @@
services:
discdrop:
image: droideparanoico/discdrop
container_name: discdrop
restart: unless-stopped
ports:
- "8080:8080"
volumes:
# Configure here your data directory if you want persistence.
- ./data:/app/data
env_file:
- .env
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Vendored
-295
View File
@@ -1,295 +0,0 @@
#!/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-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -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 "$@"
Vendored
-189
View File
@@ -1,189 +0,0 @@
<# : 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-<version>,maven-mvnd-<version>-<platform>}/<hash>
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"
@@ -29,8 +29,10 @@ public class AppSetting extends PanacheEntity {
if (s == null) {
s = new AppSetting();
s.key = key;
s.value = value;
s.persist();
} else {
s.value = value;
}
s.value = value;
}
}
@@ -8,7 +8,10 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@ApplicationScoped
public class ReleaseGroupRepository {
@@ -29,6 +32,25 @@ public class ReleaseGroupRepository {
}
}
@Transactional
public void replaceForArtist(String mbid, List<ReleaseGroupBrowseResult.ReleaseGroupDto> newDtos) {
FollowedArtist artist = FollowedArtist.findByMbid(mbid);
if (artist == null) {
return;
}
Set<String> newMbids = new HashSet<>();
for (ReleaseGroupBrowseResult.ReleaseGroupDto rg : newDtos) {
newMbids.add(rg.id);
upsertOne(artist, rg);
}
List<ReleaseGroupEntity> existing = ReleaseGroupEntity.list("artist", artist);
for (ReleaseGroupEntity rg : existing) {
if (!newMbids.contains(rg.mbid)) {
rg.delete();
}
}
}
@Transactional
public void markSynced(String mbid) {
FollowedArtist artist = FollowedArtist.findByMbid(mbid);
@@ -6,6 +6,7 @@ import io.discdrop.persistence.FollowedArtist;
import io.discdrop.persistence.ReleaseGroupEntity;
import io.discdrop.service.ArtistService;
import io.discdrop.service.FeedService;
import io.discdrop.service.SettingsService;
import io.discdrop.service.SyncService;
import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
@@ -17,8 +18,10 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Path("/artists")
public class ArtistResource {
@@ -32,10 +35,17 @@ public class ArtistResource {
@Inject
FeedService feedService;
@Inject
SettingsService settingsService;
@Inject
@Location("fragments/feed-list.html")
Template fragments_feed_list;
@Inject
@Location("fragments/feed-row.html")
Template fragments_feed_row;
@Inject
@Location("fragments/artist-row.html")
Template fragments_artist_row;
@@ -61,16 +71,16 @@ public class ArtistResource {
dto.area = new ArtistSearchResult.ArtistDto.Area();
dto.area.name = area;
}
FollowedArtist artist = artistService.follow(dto);
syncService.syncArtist(artist.mbid);
return feedFragment(0);
artistService.follow(dto);
CompletableFuture.runAsync(() -> syncService.syncArtist(mbid));
return feedFragment(0, true, true);
}
@DELETE
@Path("/{mbid}")
public TemplateInstance unfollow(@PathParam("mbid") String mbid) {
public Response unfollow(@PathParam("mbid") String mbid) {
artistService.unfollow(mbid);
return feedFragment(0);
return Response.ok("").build();
}
@GET
@@ -85,7 +95,8 @@ public class ArtistResource {
FollowedArtist artist = artistService.findArtist(mbid);
List<ArtistTypeSetting> settings = artist != null ? artistService.settingsFor(artist) : List.of();
return fragments_artist_row.data("artist", artist)
.data("settings", settings);
.data("settings", settings)
.data("syncing", syncService.isSyncing(mbid));
}
@POST
@@ -93,24 +104,28 @@ public class ArtistResource {
public TemplateInstance toggleType(@PathParam("mbid") String mbid,
@FormParam("primaryType") String primaryType,
@FormParam("enabled") boolean enabled) {
artistService.setTypeEnabled(mbid, primaryType, enabled);
CompletableFuture.runAsync(() -> syncService.resyncArtist(mbid));
FollowedArtist artist = artistService.findArtist(mbid);
if (artist != null) {
artistService.setTypeEnabled(artist, primaryType, enabled);
syncService.resyncArtist(mbid);
}
return feedFragment(0);
List<ArtistTypeSetting> settings = artist != null ? artistService.settingsFor(artist) : List.of();
return fragments_artist_row.data("artist", artist)
.data("settings", settings)
.data("syncing", true);
}
private TemplateInstance feedFragment(int offset) {
private TemplateInstance feedFragment(int offset, boolean autoRefresh, boolean syncing) {
if (offset < 0) {
offset = 0;
}
List<ReleaseGroupEntity> rows = feedService.feedPage(offset);
boolean hideFuture = settingsService.isHideFutureFeed();
List<ReleaseGroupEntity> rows = feedService.feedPage(offset, hideFuture);
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());
.data("pageSize", feedService.pageSize())
.data("autoRefresh", autoRefresh)
.data("syncing", syncing);
}
}
@@ -3,6 +3,8 @@ package io.discdrop.resource;
import io.discdrop.persistence.FollowedArtist;
import io.discdrop.persistence.ReleaseGroupEntity;
import io.discdrop.service.FeedService;
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;
@@ -20,6 +22,12 @@ public class PageResource {
@Inject
FeedService feedService;
@Inject
SyncService syncService;
@Inject
SettingsService settingsService;
@Inject
Template index;
@@ -34,24 +42,30 @@ public class PageResource {
if (offset < 0) {
offset = 0;
}
List<ReleaseGroupEntity> rows = feedService.feedPage(offset);
boolean hideFuture = settingsService.isHideFutureFeed();
List<ReleaseGroupEntity> rows = feedService.feedPage(offset, hideFuture);
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());
.data("pageSize", feedService.pageSize())
.data("autoRefresh", false)
.data("syncing", syncService.isAnySyncing());
}
@GET
@Transactional
public TemplateInstance index() {
List<ReleaseGroupEntity> rows = feedService.feedPage(0);
boolean hideFuture = settingsService.isHideFutureFeed();
List<ReleaseGroupEntity> rows = feedService.feedPage(0, hideFuture);
boolean hasMore = rows.size() == feedService.pageSize();
return index.data("rows", rows)
.data("nextOffset", rows.size())
.data("hasMore", hasMore)
.data("pageSize", feedService.pageSize())
.data("autoRefresh", false)
.data("syncing", syncService.isAnySyncing())
.data("followedCount", FollowedArtist.count())
.data("feedCount", ReleaseGroupEntity.count());
}
@@ -2,6 +2,7 @@ package io.discdrop.resource;
import io.discdrop.persistence.ReleaseGroupEntity;
import io.discdrop.service.FeedService;
import io.discdrop.service.SettingsService;
import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
@@ -27,6 +28,9 @@ public class RssResource {
@Inject
FeedService feedService;
@Inject
SettingsService settingsService;
@Inject
@Location("rss.xml")
Template rss;
@@ -40,7 +44,8 @@ public class RssResource {
@GET
@Produces(MediaType.APPLICATION_XML)
public TemplateInstance feed() {
List<ReleaseGroupEntity> entities = feedService.latest(itemCount);
boolean hideFuture = settingsService.isHideFutureRss();
List<ReleaseGroupEntity> entities = feedService.latest(itemCount, hideFuture);
List<RssItem> items = new ArrayList<>(entities.size());
for (ReleaseGroupEntity e : entities) {
String pubDate = null;
@@ -37,7 +37,9 @@ public class SettingsResource {
@POST
public TemplateInstance save(@FormParam("defaultPrimaryTypes") Set<String> defaultPrimaryTypes,
@FormParam("syncScheduleHours") int syncScheduleHours) {
@FormParam("syncScheduleHours") int syncScheduleHours,
@FormParam("hideFutureFeed") boolean hideFutureFeed,
@FormParam("hideFutureRss") boolean hideFutureRss) {
if (defaultPrimaryTypes == null) {
defaultPrimaryTypes = Set.of();
}
@@ -47,6 +49,8 @@ public class SettingsResource {
if (previous != syncScheduleHours) {
syncService.reschedule();
}
settingsService.setHideFutureFeed(hideFutureFeed);
settingsService.setHideFutureRss(hideFutureRss);
return buildPanel("saved");
}
@@ -54,6 +58,8 @@ public class SettingsResource {
return fragments_settings_panel
.data("defaultPrimaryTypes", settingsService.getDefaultPrimaryTypes())
.data("syncScheduleHours", settingsService.getSyncScheduleHours())
.data("hideFutureFeed", settingsService.isHideFutureFeed())
.data("hideFutureRss", settingsService.isHideFutureRss())
.data("allPrimaryTypes", SettingsService.ALL_PRIMARY_TYPES)
.data("scheduleOptions", SCHEDULE_OPTIONS)
.data("flash", flash);
@@ -63,7 +63,11 @@ public class ArtistService {
}
@Transactional
public void setTypeEnabled(FollowedArtist artist, String primaryType, boolean enabled) {
public void setTypeEnabled(String mbid, String primaryType, boolean enabled) {
FollowedArtist artist = FollowedArtist.findByMbid(mbid);
if (artist == null) {
return;
}
for (ArtistTypeSetting s : ArtistTypeSetting.findByArtist(artist)) {
if (s.primaryType.equalsIgnoreCase(primaryType)) {
s.enabled = enabled;
@@ -6,6 +6,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.LocalDate;
import java.util.List;
@ApplicationScoped
@@ -17,21 +18,27 @@ public class FeedService {
private static final String ORDER_HQL =
"ORDER BY firstReleaseDate DESC NULLS LAST, discoveredAt DESC";
private PanacheQuery<ReleaseGroupEntity> orderedQuery() {
private static final String WHERE_ORDER_HQL =
"WHERE firstReleaseDate IS NULL OR firstReleaseDate <= ?1 " + ORDER_HQL;
private PanacheQuery<ReleaseGroupEntity> orderedQuery(boolean hideFuture) {
if (hideFuture) {
return ReleaseGroupEntity.find(WHERE_ORDER_HQL, LocalDate.now());
}
return ReleaseGroupEntity.find(ORDER_HQL);
}
@Transactional
public List<ReleaseGroupEntity> feedPage(int offset) {
public List<ReleaseGroupEntity> feedPage(int offset, boolean hideFuture) {
if (offset < 0) {
offset = 0;
}
return orderedQuery().range(offset, offset + pageSize - 1).list();
return orderedQuery(hideFuture).range(offset, offset + pageSize - 1).list();
}
@Transactional
public List<ReleaseGroupEntity> latest(int count) {
return orderedQuery().range(0, Math.max(0, count - 1)).list();
public List<ReleaseGroupEntity> latest(int count, boolean hideFuture) {
return orderedQuery(hideFuture).range(0, Math.max(0, count - 1)).list();
}
public int pageSize() {
@@ -2,6 +2,7 @@ package io.discdrop.service;
import io.discdrop.persistence.AppSetting;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.util.Arrays;
import java.util.LinkedHashSet;
@@ -14,6 +15,8 @@ public class SettingsService {
public static final String KEY_DEFAULT_PRIMARY_TYPES = "defaultPrimaryTypes";
public static final String KEY_SYNC_SCHEDULE_HOURS = "syncScheduleHours";
public static final String KEY_HIDE_FUTURE_FEED = "hideFutureFeed";
public static final String KEY_HIDE_FUTURE_RSS = "hideFutureRss";
public static final List<String> ALL_PRIMARY_TYPES = List.of("album", "single", "ep", "broadcast", "other");
@@ -22,6 +25,7 @@ public class SettingsService {
return parseTypes(raw);
}
@Transactional
public void setDefaultPrimaryTypes(Set<String> types) {
AppSetting.set(KEY_DEFAULT_PRIMARY_TYPES, String.join(",", types));
}
@@ -35,10 +39,29 @@ public class SettingsService {
}
}
@Transactional
public void setSyncScheduleHours(int hours) {
AppSetting.set(KEY_SYNC_SCHEDULE_HOURS, String.valueOf(hours));
}
public boolean isHideFutureFeed() {
return Boolean.parseBoolean(AppSetting.get(KEY_HIDE_FUTURE_FEED, "false"));
}
@Transactional
public void setHideFutureFeed(boolean hide) {
AppSetting.set(KEY_HIDE_FUTURE_FEED, String.valueOf(hide));
}
public boolean isHideFutureRss() {
return Boolean.parseBoolean(AppSetting.get(KEY_HIDE_FUTURE_RSS, "false"));
}
@Transactional
public void setHideFutureRss(boolean hide) {
AppSetting.set(KEY_HIDE_FUTURE_RSS, String.valueOf(hide));
}
private Set<String> parseTypes(String raw) {
if (raw == null || raw.isBlank()) {
return new LinkedHashSet<>();
@@ -10,7 +10,10 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
@@ -31,6 +34,8 @@ public class SyncService {
@Inject
SettingsService settingsService;
private final Set<String> syncing = ConcurrentHashMap.newKeySet();
private final ScheduledExecutorService executor =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "discdrop-sync");
@@ -73,27 +78,61 @@ public class SyncService {
}
}
public void syncArtist(String mbid) {
Set<String> enabledTypes = artistService.enabledTypes(mbid);
String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes);
public boolean isSyncing(String mbid) {
return syncing.contains(mbid);
}
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;
public boolean isAnySyncing() {
return !syncing.isEmpty();
}
public void syncArtist(String mbid) {
syncing.add(mbid);
try {
Set<String> 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.upsertPage(mbid, page.releaseGroups);
offset += page.releaseGroups.size();
more = page.releaseGroups.size() == limit && offset < page.count;
repo.markSynced(mbid);
} finally {
syncing.remove(mbid);
}
repo.markSynced(mbid);
}
public void resyncArtist(String mbid) {
repo.deleteByArtistMbid(mbid);
syncArtist(mbid);
syncing.add(mbid);
try {
Set<String> enabledTypes = artistService.enabledTypes(mbid);
String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes);
List<ReleaseGroupBrowseResult.ReleaseGroupDto> all = new ArrayList<>();
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;
}
all.addAll(page.releaseGroups);
offset += page.releaseGroups.size();
more = page.releaseGroups.size() == limit && offset < page.count;
}
repo.replaceForArtist(mbid, all);
repo.markSynced(mbid);
} finally {
syncing.remove(mbid);
}
}
}
@@ -0,0 +1,18 @@
/* DiscDrop custom styles */
html { scroll-behavior: smooth; }
/* Hide empty search dropdown */
#search-dropdown:empty { display: none; }
/* Remove native search input artifacts */
input[type="text"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
/* Cover art in feed */
.cover-art {
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}

Before

Width:  |  Height:  |  Size: 469 B

After

Width:  |  Height:  |  Size: 469 B

Before

Width:  |  Height:  |  Size: 676 B

After

Width:  |  Height:  |  Size: 676 B

Before

Width:  |  Height:  |  Size: 418 B

After

Width:  |  Height:  |  Size: 418 B

+1 -1
View File
@@ -11,7 +11,7 @@ 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 ([email protected])
discdrop.mbz.user-agent=${MBZ_USER_AGENT}
discdrop.mbz.rate-limit-ms=1000
discdrop.feed.page-size=25
discdrop.rss.item-count=50
-3
View File
@@ -1,3 +0,0 @@
/* DiscDrop custom styles */
html { scroll-behavior: smooth; }
#search-dropdown:empty { display: none; }
+12 -5
View File
@@ -4,22 +4,25 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>DiscDrop Artists</title>
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg"/>
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg"/>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/[email protected]"></script>
<link rel="stylesheet" href="/static/css/app.css"/>
<script src="/static/js/app.js" defer></script>
<link rel="stylesheet" href="/css/app.css"/>
<script src="/js/app.js" defer></script>
</head>
<body>
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300">
<div class="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
<div class="flex items-center gap-2">
<img src="/static/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
<img src="/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
<span class="text-xl font-bold">DiscDrop</span>
</div>
<div class="flex items-center gap-2">
<a href="/" class="btn btn-ghost btn-sm">Feed</a>
<a href="/rss" class="btn btn-ghost btn-circle" aria-label="RSS feed" title="RSS feed">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"/></svg>
</a>
<button id="theme-switcher" class="btn btn-ghost btn-circle" aria-label="Toggle theme" onclick="DiscDrop.toggleTheme()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
</button>
@@ -36,7 +39,11 @@
</div>
{#else}
{#for artist in artists}
<div hx-get="/artists/{artist.mbid}/row" hx-trigger="revealed" hx-swap="innerHTML">
<div id="artist-{artist.mbid}"
hx-get="/artists/{artist.mbid}/row"
hx-trigger="revealed"
hx-target="this"
hx-swap="innerHTML">
<div class="loading loading-spinner loading-sm"></div>
</div>
{/for}
@@ -9,8 +9,8 @@
</div>
<button class="btn btn-ghost btn-xs text-error"
hx-delete="/artists/{artist.mbid}"
hx-target="closest div.space-y-3"
hx-swap="outerHTML"
hx-target="#artist-{artist.mbid}"
hx-swap="delete"
hx-on::after-request="DiscDrop.refreshFeed()">
Unfollow
</button>
@@ -20,17 +20,30 @@
{#for s in settings}
<label class="label cursor-pointer gap-1 badge badge-outline py-2">
<input type="checkbox"
name="enabled"
class="checkbox checkbox-xs"
value="true"
{#if s.enabled}checked{/if}
hx-post="/artists/{artist.mbid}/types"
hx-vals='{"primaryType":"{s.primaryType}"}'
hx-include="this"
hx-target="#feed-list"
hx-target="#artist-{artist.mbid}"
hx-swap="innerHTML"
hx-on::after-request="DiscDrop.refreshFeed()"/>
<span class="text-xs">{s.primaryType}</span>
</label>
{/for}
</div>
{#if syncing?? && syncing}
<div class="flex items-center gap-2 text-sm text-base-content/60 pt-1">
<span class="loading loading-spinner loading-xs"></span>
<span>Syncing releases from MusicBrainz…</span>
</div>
<div hx-get="/artists/{artist.mbid}/row"
hx-target="#artist-{artist.mbid}"
hx-swap="innerHTML"
hx-trigger="load delay:3s"
class="hidden"></div>
{/if}
</div>
@@ -1,3 +1,14 @@
{#if syncing?? && syncing}
<div class="flex items-center justify-center gap-2 py-2 text-base-content/60">
<span class="loading loading-spinner loading-sm text-primary"></span>
<span class="text-sm">Fetching releases from MusicBrainz…</span>
</div>
<div hx-get="/feed?offset=0"
hx-target="#feed-list"
hx-swap="innerHTML"
hx-trigger="load delay:3s"
class="hidden"></div>
{/if}
{#for row in rows}
{#include fragments/feed-row.html row=row /}
{/for}
@@ -10,7 +21,7 @@
Load more
</button>
{/if}
{#if !hasMore && rows.empty}
{#if !hasMore && rows.empty && !(syncing?? && syncing)}
<div class="text-center text-base-content/60 py-12">
<p class="text-lg">No releases yet.</p>
<p class="text-sm">Search for an artist above and click <strong>Follow</strong> to start tracking their release groups.</p>
@@ -1,22 +1,22 @@
<div class="flex gap-3 items-center bg-base-100 rounded-box border border-base-300 p-3 shadow-sm">
<div class="flex gap-3 items-center bg-base-100 rounded-box border border-base-300 p-2.5 shadow-sm">
<img src="https://coverartarchive.org/release-group/{row.mbid}/front"
alt="cover"
loading="lazy"
class="w-16 h-16 rounded object-cover bg-base-200"
onerror="this.onerror=null;this.src='/static/img/no-cover.svg'"/>
class="cover-art w-40 h-40 object-cover shrink-0"
onerror="this.onerror=null;this.src='/img/no-cover.svg'"/>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold truncate">{row.artist.name}</span>
{#if row.primaryType??}<span class="badge badge-primary badge-sm">{row.primaryType}</span>{/if}
{#if row.secondaryTypes??}<span class="badge badge-ghost badge-sm">{row.secondaryTypes}</span>{/if}
</div>
<a href="{row.mbzUrl}" target="_blank" rel="noopener" class="link link-hover text-base-content/90 block truncate">
<div class="flex-1 min-w-0 flex flex-col justify-center gap-1">
<div class="font-semibold text-sm text-base-content/70 truncate">{row.artist.name}</div>
<a href="{row.mbzUrl}" target="_blank" rel="noopener"
class="link link-hover text-base font-bold leading-tight block truncate">
{row.title}
</a>
</div>
<div class="text-right text-sm text-base-content/70 shrink-0">
{#if row.firstReleaseDateRaw??}{row.firstReleaseDateRaw}{#else}—{/if}
<div class="flex flex-wrap items-center gap-2 mt-0.5">
{#if row.primaryType??}<span class="badge badge-primary badge-sm">{row.primaryType}</span>{/if}
{#if row.secondaryTypes??}<span class="badge badge-ghost badge-sm">{row.secondaryTypes}</span>{/if}
<span class="text-sm text-base-content/60">
{#if row.firstReleaseDateRaw??}{row.firstReleaseDateRaw}{#else}—{/if}
</span>
</div>
</div>
</div>
@@ -34,6 +34,23 @@
</div>
</div>
<div class="divider my-1"></div>
<div class="text-sm font-semibold mb-1">Future releases</div>
<div class="space-y-2">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="hideFutureFeed" value="true"
class="checkbox checkbox-sm"
{#if hideFutureFeed}checked{/if}/>
<span class="text-xs">Hide unreleased in feed</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="hideFutureRss" value="true"
class="checkbox checkbox-sm"
{#if hideFutureRss}checked{/if}/>
<span class="text-xs">Hide unreleased in RSS</span>
</label>
</div>
<button class="btn btn-primary btn-sm w-full">Save</button>
</form>
</div>
+21 -18
View File
@@ -4,38 +4,37 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>DiscDrop</title>
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg"/>
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg"/>
<link rel="alternate" type="application/rss+xml" title="DiscDrop New Releases" href="/rss"/>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/[email protected]"></script>
<link rel="stylesheet" href="/static/css/app.css"/>
<script src="/static/js/app.js" defer></script>
<link rel="stylesheet" href="/css/app.css"/>
<script src="/js/app.js" defer></script>
</head>
<body>
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300">
<div class="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
<div class="flex items-center gap-2">
<img src="/static/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
<img src="/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
<span class="text-xl font-bold">DiscDrop</span>
</div>
<div id="search-wrap" class="relative flex-1 min-w-[220px] max-w-xl">
<form id="search-form" class="join w-full" autocomplete="off">
<input
id="search-input"
type="search"
name="q"
placeholder="Search MusicBrainz artists…"
class="input input-bordered join-item w-full"
hx-get="/search/artists"
hx-trigger="input changed delay:300ms"
hx-target="#search-dropdown"
hx-swap="innerHTML"
hx-include="this"/>
</form>
<input
id="search-input"
type="text"
name="q"
placeholder="Search MusicBrainz artists…"
class="input input-bordered w-full"
autocomplete="off"
hx-get="/search/artists"
hx-trigger="input changed delay:300ms"
hx-target="#search-dropdown"
hx-swap="innerHTML"
hx-include="this"/>
<div id="search-dropdown"
class="absolute left-0 right-0 z-40 mt-1 bg-base-100 rounded-box shadow-lg border border-base-300 max-h-96 overflow-y-auto"></div>
class="absolute left-0 right-0 top-full z-40 mt-1 bg-base-100 rounded-box shadow-lg border border-base-300 max-h-96 overflow-y-auto"></div>
</div>
<div class="flex items-center gap-2">
@@ -57,6 +56,10 @@
<svg id="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
</button>
<a href="/rss" class="btn btn-ghost btn-circle" aria-label="RSS feed" title="RSS feed">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"/></svg>
</a>
<a href="/artists" class="btn btn-ghost btn-sm" aria-label="Artists">Artists</a>
</div>
</div>