Compare commits

..
1 Commits
35 changed files with 583 additions and 505 deletions
-7
View File
@@ -1,7 +0,0 @@
data/
target/
.git/
.gitignore
README.md
*.md
.DS_Store
-2
View File
@@ -1,2 +0,0 @@
# Required: MusicBrainz User-Agent (must be a meaningful contact string)
MBZ_USER_AGENT=DiscDrop/1.0 ([email protected])
-29
View File
@@ -1,29 +0,0 @@
# 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
@@ -1,45 +0,0 @@
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,8 +2,6 @@
target/ target/
*.jar *.jar
*.war *.war
mvnw
mvnw.cmd
# IDE # IDE
.idea/ .idea/
@@ -23,8 +21,5 @@ data/
*.mv.db *.mv.db
*.trace.db *.trace.db
# Environment (personal config)
.env
# OS # OS
.DS_Store .DS_Store
-16
View File
@@ -1,16 +0,0 @@
# 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
@@ -1,21 +0,0 @@
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.
+14 -80
View File
@@ -1,95 +1,29 @@
# DiscDrop 💿 # DiscDrop
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. 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. Built with Java 21 + Quarkus, htmx, and daisyUI.
### Feed view ## Run (dev)
![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 ```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 ./mvnw quarkus:dev
``` ```
Open http://localhost:8080 App at http://localhost:8080 — RSS at http://localhost:8080/rss
## Configuration ## Configuration (`application.properties`)
All settings are in `application.properties`. Key values can be overridden via environment variables: | 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. |
| Property | Env var | Default | Notes | The H2 file database lives in `./data/discdrop.mv.db` (gitignored).
|---|---|---|---|
| `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 ## Architecture
- **Backend**: Quarkus (Java 21, JAX-RS, Hibernate ORM with Panache) — H2 file database, zero external services See `../discdrop-plan.md` for the full design.
- **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
@@ -1,12 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 277 KiB

Vendored Executable
+295
View File
@@ -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-<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
@@ -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-<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,10 +29,8 @@ public class AppSetting extends PanacheEntity {
if (s == null) { if (s == null) {
s = new AppSetting(); s = new AppSetting();
s.key = key; s.key = key;
s.value = value;
s.persist(); s.persist();
} else {
s.value = value;
} }
s.value = value;
} }
} }
@@ -8,10 +8,7 @@ import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
@ApplicationScoped @ApplicationScoped
public class ReleaseGroupRepository { public class ReleaseGroupRepository {
@@ -32,25 +29,6 @@ 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 @Transactional
public void markSynced(String mbid) { public void markSynced(String mbid) {
FollowedArtist artist = FollowedArtist.findByMbid(mbid); FollowedArtist artist = FollowedArtist.findByMbid(mbid);
@@ -6,7 +6,6 @@ import io.discdrop.persistence.FollowedArtist;
import io.discdrop.persistence.ReleaseGroupEntity; import io.discdrop.persistence.ReleaseGroupEntity;
import io.discdrop.service.ArtistService; import io.discdrop.service.ArtistService;
import io.discdrop.service.FeedService; import io.discdrop.service.FeedService;
import io.discdrop.service.SettingsService;
import io.discdrop.service.SyncService; import io.discdrop.service.SyncService;
import io.quarkus.qute.Location; import io.quarkus.qute.Location;
import io.quarkus.qute.Template; import io.quarkus.qute.Template;
@@ -18,10 +17,8 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
@Path("/artists") @Path("/artists")
public class ArtistResource { public class ArtistResource {
@@ -35,17 +32,10 @@ public class ArtistResource {
@Inject @Inject
FeedService feedService; FeedService feedService;
@Inject
SettingsService settingsService;
@Inject @Inject
@Location("fragments/feed-list.html") @Location("fragments/feed-list.html")
Template fragments_feed_list; Template fragments_feed_list;
@Inject
@Location("fragments/feed-row.html")
Template fragments_feed_row;
@Inject @Inject
@Location("fragments/artist-row.html") @Location("fragments/artist-row.html")
Template fragments_artist_row; Template fragments_artist_row;
@@ -71,16 +61,16 @@ public class ArtistResource {
dto.area = new ArtistSearchResult.ArtistDto.Area(); dto.area = new ArtistSearchResult.ArtistDto.Area();
dto.area.name = area; dto.area.name = area;
} }
artistService.follow(dto); FollowedArtist artist = artistService.follow(dto);
CompletableFuture.runAsync(() -> syncService.syncArtist(mbid)); syncService.syncArtist(artist.mbid);
return feedFragment(0, true, true); return feedFragment(0);
} }
@DELETE @DELETE
@Path("/{mbid}") @Path("/{mbid}")
public Response unfollow(@PathParam("mbid") String mbid) { public TemplateInstance unfollow(@PathParam("mbid") String mbid) {
artistService.unfollow(mbid); artistService.unfollow(mbid);
return Response.ok("").build(); return feedFragment(0);
} }
@GET @GET
@@ -95,8 +85,7 @@ public class ArtistResource {
FollowedArtist artist = artistService.findArtist(mbid); FollowedArtist artist = artistService.findArtist(mbid);
List<ArtistTypeSetting> settings = artist != null ? artistService.settingsFor(artist) : List.of(); List<ArtistTypeSetting> settings = artist != null ? artistService.settingsFor(artist) : List.of();
return fragments_artist_row.data("artist", artist) return fragments_artist_row.data("artist", artist)
.data("settings", settings) .data("settings", settings);
.data("syncing", syncService.isSyncing(mbid));
} }
@POST @POST
@@ -104,28 +93,24 @@ public class ArtistResource {
public TemplateInstance toggleType(@PathParam("mbid") String mbid, public TemplateInstance toggleType(@PathParam("mbid") String mbid,
@FormParam("primaryType") String primaryType, @FormParam("primaryType") String primaryType,
@FormParam("enabled") boolean enabled) { @FormParam("enabled") boolean enabled) {
artistService.setTypeEnabled(mbid, primaryType, enabled);
CompletableFuture.runAsync(() -> syncService.resyncArtist(mbid));
FollowedArtist artist = artistService.findArtist(mbid); FollowedArtist artist = artistService.findArtist(mbid);
List<ArtistTypeSetting> settings = artist != null ? artistService.settingsFor(artist) : List.of(); if (artist != null) {
return fragments_artist_row.data("artist", artist) artistService.setTypeEnabled(artist, primaryType, enabled);
.data("settings", settings) syncService.resyncArtist(mbid);
.data("syncing", true); }
return feedFragment(0);
} }
private TemplateInstance feedFragment(int offset, boolean autoRefresh, boolean syncing) { private TemplateInstance feedFragment(int offset) {
if (offset < 0) { if (offset < 0) {
offset = 0; offset = 0;
} }
boolean hideFuture = settingsService.isHideFutureFeed(); List<ReleaseGroupEntity> rows = feedService.feedPage(offset);
List<ReleaseGroupEntity> rows = feedService.feedPage(offset, hideFuture);
int nextOffset = offset + rows.size(); int nextOffset = offset + rows.size();
boolean hasMore = rows.size() == feedService.pageSize(); boolean hasMore = rows.size() == feedService.pageSize();
return fragments_feed_list.data("rows", rows) return fragments_feed_list.data("rows", rows)
.data("nextOffset", nextOffset) .data("nextOffset", nextOffset)
.data("hasMore", hasMore) .data("hasMore", hasMore)
.data("pageSize", feedService.pageSize()) .data("pageSize", feedService.pageSize());
.data("autoRefresh", autoRefresh)
.data("syncing", syncing);
} }
} }
@@ -3,8 +3,6 @@ package io.discdrop.resource;
import io.discdrop.persistence.FollowedArtist; import io.discdrop.persistence.FollowedArtist;
import io.discdrop.persistence.ReleaseGroupEntity; import io.discdrop.persistence.ReleaseGroupEntity;
import io.discdrop.service.FeedService; import io.discdrop.service.FeedService;
import io.discdrop.service.SettingsService;
import io.discdrop.service.SyncService;
import io.quarkus.qute.Location; import io.quarkus.qute.Location;
import io.quarkus.qute.Template; import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance; import io.quarkus.qute.TemplateInstance;
@@ -22,12 +20,6 @@ public class PageResource {
@Inject @Inject
FeedService feedService; FeedService feedService;
@Inject
SyncService syncService;
@Inject
SettingsService settingsService;
@Inject @Inject
Template index; Template index;
@@ -42,30 +34,24 @@ public class PageResource {
if (offset < 0) { if (offset < 0) {
offset = 0; offset = 0;
} }
boolean hideFuture = settingsService.isHideFutureFeed(); List<ReleaseGroupEntity> rows = feedService.feedPage(offset);
List<ReleaseGroupEntity> rows = feedService.feedPage(offset, hideFuture);
int nextOffset = offset + rows.size(); int nextOffset = offset + rows.size();
boolean hasMore = rows.size() == feedService.pageSize(); boolean hasMore = rows.size() == feedService.pageSize();
return fragments_feed_list.data("rows", rows) return fragments_feed_list.data("rows", rows)
.data("nextOffset", nextOffset) .data("nextOffset", nextOffset)
.data("hasMore", hasMore) .data("hasMore", hasMore)
.data("pageSize", feedService.pageSize()) .data("pageSize", feedService.pageSize());
.data("autoRefresh", false)
.data("syncing", syncService.isAnySyncing());
} }
@GET @GET
@Transactional @Transactional
public TemplateInstance index() { public TemplateInstance index() {
boolean hideFuture = settingsService.isHideFutureFeed(); List<ReleaseGroupEntity> rows = feedService.feedPage(0);
List<ReleaseGroupEntity> rows = feedService.feedPage(0, hideFuture);
boolean hasMore = rows.size() == feedService.pageSize(); boolean hasMore = rows.size() == feedService.pageSize();
return index.data("rows", rows) return index.data("rows", rows)
.data("nextOffset", rows.size()) .data("nextOffset", rows.size())
.data("hasMore", hasMore) .data("hasMore", hasMore)
.data("pageSize", feedService.pageSize()) .data("pageSize", feedService.pageSize())
.data("autoRefresh", false)
.data("syncing", syncService.isAnySyncing())
.data("followedCount", FollowedArtist.count()) .data("followedCount", FollowedArtist.count())
.data("feedCount", ReleaseGroupEntity.count()); .data("feedCount", ReleaseGroupEntity.count());
} }
@@ -2,7 +2,6 @@ package io.discdrop.resource;
import io.discdrop.persistence.ReleaseGroupEntity; import io.discdrop.persistence.ReleaseGroupEntity;
import io.discdrop.service.FeedService; import io.discdrop.service.FeedService;
import io.discdrop.service.SettingsService;
import io.quarkus.qute.Location; import io.quarkus.qute.Location;
import io.quarkus.qute.Template; import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance; import io.quarkus.qute.TemplateInstance;
@@ -28,9 +27,6 @@ public class RssResource {
@Inject @Inject
FeedService feedService; FeedService feedService;
@Inject
SettingsService settingsService;
@Inject @Inject
@Location("rss.xml") @Location("rss.xml")
Template rss; Template rss;
@@ -44,8 +40,7 @@ public class RssResource {
@GET @GET
@Produces(MediaType.APPLICATION_XML) @Produces(MediaType.APPLICATION_XML)
public TemplateInstance feed() { public TemplateInstance feed() {
boolean hideFuture = settingsService.isHideFutureRss(); List<ReleaseGroupEntity> entities = feedService.latest(itemCount);
List<ReleaseGroupEntity> entities = feedService.latest(itemCount, hideFuture);
List<RssItem> items = new ArrayList<>(entities.size()); List<RssItem> items = new ArrayList<>(entities.size());
for (ReleaseGroupEntity e : entities) { for (ReleaseGroupEntity e : entities) {
String pubDate = null; String pubDate = null;
@@ -37,9 +37,7 @@ public class SettingsResource {
@POST @POST
public TemplateInstance save(@FormParam("defaultPrimaryTypes") Set<String> defaultPrimaryTypes, 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) { if (defaultPrimaryTypes == null) {
defaultPrimaryTypes = Set.of(); defaultPrimaryTypes = Set.of();
} }
@@ -49,8 +47,6 @@ public class SettingsResource {
if (previous != syncScheduleHours) { if (previous != syncScheduleHours) {
syncService.reschedule(); syncService.reschedule();
} }
settingsService.setHideFutureFeed(hideFutureFeed);
settingsService.setHideFutureRss(hideFutureRss);
return buildPanel("saved"); return buildPanel("saved");
} }
@@ -58,8 +54,6 @@ public class SettingsResource {
return fragments_settings_panel return fragments_settings_panel
.data("defaultPrimaryTypes", settingsService.getDefaultPrimaryTypes()) .data("defaultPrimaryTypes", settingsService.getDefaultPrimaryTypes())
.data("syncScheduleHours", settingsService.getSyncScheduleHours()) .data("syncScheduleHours", settingsService.getSyncScheduleHours())
.data("hideFutureFeed", settingsService.isHideFutureFeed())
.data("hideFutureRss", settingsService.isHideFutureRss())
.data("allPrimaryTypes", SettingsService.ALL_PRIMARY_TYPES) .data("allPrimaryTypes", SettingsService.ALL_PRIMARY_TYPES)
.data("scheduleOptions", SCHEDULE_OPTIONS) .data("scheduleOptions", SCHEDULE_OPTIONS)
.data("flash", flash); .data("flash", flash);
@@ -63,11 +63,7 @@ public class ArtistService {
} }
@Transactional @Transactional
public void setTypeEnabled(String mbid, String primaryType, boolean enabled) { public void setTypeEnabled(FollowedArtist artist, String primaryType, boolean enabled) {
FollowedArtist artist = FollowedArtist.findByMbid(mbid);
if (artist == null) {
return;
}
for (ArtistTypeSetting s : ArtistTypeSetting.findByArtist(artist)) { for (ArtistTypeSetting s : ArtistTypeSetting.findByArtist(artist)) {
if (s.primaryType.equalsIgnoreCase(primaryType)) { if (s.primaryType.equalsIgnoreCase(primaryType)) {
s.enabled = enabled; s.enabled = enabled;
@@ -6,7 +6,6 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.LocalDate;
import java.util.List; import java.util.List;
@ApplicationScoped @ApplicationScoped
@@ -18,27 +17,21 @@ public class FeedService {
private static final String ORDER_HQL = private static final String ORDER_HQL =
"ORDER BY firstReleaseDate DESC NULLS LAST, discoveredAt DESC"; "ORDER BY firstReleaseDate DESC NULLS LAST, discoveredAt DESC";
private static final String WHERE_ORDER_HQL = private PanacheQuery<ReleaseGroupEntity> orderedQuery() {
"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); return ReleaseGroupEntity.find(ORDER_HQL);
} }
@Transactional @Transactional
public List<ReleaseGroupEntity> feedPage(int offset, boolean hideFuture) { public List<ReleaseGroupEntity> feedPage(int offset) {
if (offset < 0) { if (offset < 0) {
offset = 0; offset = 0;
} }
return orderedQuery(hideFuture).range(offset, offset + pageSize - 1).list(); return orderedQuery().range(offset, offset + pageSize - 1).list();
} }
@Transactional @Transactional
public List<ReleaseGroupEntity> latest(int count, boolean hideFuture) { public List<ReleaseGroupEntity> latest(int count) {
return orderedQuery(hideFuture).range(0, Math.max(0, count - 1)).list(); return orderedQuery().range(0, Math.max(0, count - 1)).list();
} }
public int pageSize() { public int pageSize() {
@@ -2,7 +2,6 @@ package io.discdrop.service;
import io.discdrop.persistence.AppSetting; import io.discdrop.persistence.AppSetting;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
@@ -15,8 +14,6 @@ public class SettingsService {
public static final String KEY_DEFAULT_PRIMARY_TYPES = "defaultPrimaryTypes"; public static final String KEY_DEFAULT_PRIMARY_TYPES = "defaultPrimaryTypes";
public static final String KEY_SYNC_SCHEDULE_HOURS = "syncScheduleHours"; 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"); public static final List<String> ALL_PRIMARY_TYPES = List.of("album", "single", "ep", "broadcast", "other");
@@ -25,7 +22,6 @@ public class SettingsService {
return parseTypes(raw); return parseTypes(raw);
} }
@Transactional
public void setDefaultPrimaryTypes(Set<String> types) { public void setDefaultPrimaryTypes(Set<String> types) {
AppSetting.set(KEY_DEFAULT_PRIMARY_TYPES, String.join(",", types)); AppSetting.set(KEY_DEFAULT_PRIMARY_TYPES, String.join(",", types));
} }
@@ -39,29 +35,10 @@ public class SettingsService {
} }
} }
@Transactional
public void setSyncScheduleHours(int hours) { public void setSyncScheduleHours(int hours) {
AppSetting.set(KEY_SYNC_SCHEDULE_HOURS, String.valueOf(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) { private Set<String> parseTypes(String raw) {
if (raw == null || raw.isBlank()) { if (raw == null || raw.isBlank()) {
return new LinkedHashSet<>(); return new LinkedHashSet<>();
@@ -10,10 +10,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes; import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
@@ -34,8 +31,6 @@ public class SyncService {
@Inject @Inject
SettingsService settingsService; SettingsService settingsService;
private final Set<String> syncing = ConcurrentHashMap.newKeySet();
private final ScheduledExecutorService executor = private final ScheduledExecutorService executor =
Executors.newSingleThreadScheduledExecutor(r -> { Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "discdrop-sync"); Thread t = new Thread(r, "discdrop-sync");
@@ -78,61 +73,27 @@ public class SyncService {
} }
} }
public boolean isSyncing(String mbid) {
return syncing.contains(mbid);
}
public boolean isAnySyncing() {
return !syncing.isEmpty();
}
public void syncArtist(String mbid) { public void syncArtist(String mbid) {
syncing.add(mbid); Set<String> enabledTypes = artistService.enabledTypes(mbid);
try { String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes);
Set<String> enabledTypes = artistService.enabledTypes(mbid);
String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes);
int limit = 100; int limit = 100;
int offset = 0; int offset = 0;
boolean more = true; boolean more = true;
while (more) { while (more) {
ReleaseGroupBrowseResult page = mbzService.browseReleaseGroups(mbid, typeFilter, limit, offset); ReleaseGroupBrowseResult page = mbzService.browseReleaseGroups(mbid, typeFilter, limit, offset);
if (page == null || page.releaseGroups == null || page.releaseGroups.isEmpty()) { if (page == null || page.releaseGroups == null || page.releaseGroups.isEmpty()) {
break; break;
}
repo.upsertPage(mbid, page.releaseGroups);
offset += page.releaseGroups.size();
more = page.releaseGroups.size() == limit && offset < page.count;
} }
repo.markSynced(mbid); repo.upsertPage(mbid, page.releaseGroups);
} finally { offset += page.releaseGroups.size();
syncing.remove(mbid); more = page.releaseGroups.size() == limit && offset < page.count;
} }
repo.markSynced(mbid);
} }
public void resyncArtist(String mbid) { public void resyncArtist(String mbid) {
syncing.add(mbid); repo.deleteByArtistMbid(mbid);
try { syncArtist(mbid);
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);
}
} }
} }
@@ -1,18 +0,0 @@
/* 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);
}
+1 -1
View File
@@ -11,7 +11,7 @@ quarkus.hibernate-orm.database.generation=update
# MusicBrainz REST client (configKey="musicbrainz") # MusicBrainz REST client (configKey="musicbrainz")
quarkus.rest-client."musicbrainz".url=https://musicbrainz.org/ws/2 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 # User-Agent (MBZ requires a meaningful contact string) - override per environment, never hardcode in code
discdrop.mbz.user-agent=${MBZ_USER_AGENT} discdrop.mbz.user-agent=DiscDrop/1.0 ([email protected])
discdrop.mbz.rate-limit-ms=1000 discdrop.mbz.rate-limit-ms=1000
discdrop.feed.page-size=25 discdrop.feed.page-size=25
discdrop.rss.item-count=50 discdrop.rss.item-count=50
+3
View File
@@ -0,0 +1,3 @@
/* DiscDrop custom styles */
html { scroll-behavior: smooth; }
#search-dropdown:empty { display: none; }

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

+5 -12
View File
@@ -4,25 +4,22 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>DiscDrop Artists</title> <title>DiscDrop Artists</title>
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg"/>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/> <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://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/[email protected]"></script> <script src="https://unpkg.com/[email protected]"></script>
<link rel="stylesheet" href="/css/app.css"/> <link rel="stylesheet" href="/static/css/app.css"/>
<script src="/js/app.js" defer></script> <script src="/static/js/app.js" defer></script>
</head> </head>
<body> <body>
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300"> <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="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/> <img src="/static/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
<span class="text-xl font-bold">DiscDrop</span> <span class="text-xl font-bold">DiscDrop</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="/" class="btn btn-ghost btn-sm">Feed</a> <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()"> <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> <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> </button>
@@ -39,11 +36,7 @@
</div> </div>
{#else} {#else}
{#for artist in artists} {#for artist in artists}
<div id="artist-{artist.mbid}" <div hx-get="/artists/{artist.mbid}/row" hx-trigger="revealed" hx-swap="innerHTML">
hx-get="/artists/{artist.mbid}/row"
hx-trigger="revealed"
hx-target="this"
hx-swap="innerHTML">
<div class="loading loading-spinner loading-sm"></div> <div class="loading loading-spinner loading-sm"></div>
</div> </div>
{/for} {/for}
@@ -9,8 +9,8 @@
</div> </div>
<button class="btn btn-ghost btn-xs text-error" <button class="btn btn-ghost btn-xs text-error"
hx-delete="/artists/{artist.mbid}" hx-delete="/artists/{artist.mbid}"
hx-target="#artist-{artist.mbid}" hx-target="closest div.space-y-3"
hx-swap="delete" hx-swap="outerHTML"
hx-on::after-request="DiscDrop.refreshFeed()"> hx-on::after-request="DiscDrop.refreshFeed()">
Unfollow Unfollow
</button> </button>
@@ -20,30 +20,17 @@
{#for s in settings} {#for s in settings}
<label class="label cursor-pointer gap-1 badge badge-outline py-2"> <label class="label cursor-pointer gap-1 badge badge-outline py-2">
<input type="checkbox" <input type="checkbox"
name="enabled"
class="checkbox checkbox-xs" class="checkbox checkbox-xs"
value="true" value="true"
{#if s.enabled}checked{/if} {#if s.enabled}checked{/if}
hx-post="/artists/{artist.mbid}/types" hx-post="/artists/{artist.mbid}/types"
hx-vals='{"primaryType":"{s.primaryType}"}' hx-vals='{"primaryType":"{s.primaryType}"}'
hx-include="this" hx-include="this"
hx-target="#artist-{artist.mbid}" hx-target="#feed-list"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-on::after-request="DiscDrop.refreshFeed()"/> hx-on::after-request="DiscDrop.refreshFeed()"/>
<span class="text-xs">{s.primaryType}</span> <span class="text-xs">{s.primaryType}</span>
</label> </label>
{/for} {/for}
</div> </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> </div>
@@ -1,14 +1,3 @@
{#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} {#for row in rows}
{#include fragments/feed-row.html row=row /} {#include fragments/feed-row.html row=row /}
{/for} {/for}
@@ -21,7 +10,7 @@
Load more Load more
</button> </button>
{/if} {/if}
{#if !hasMore && rows.empty && !(syncing?? && syncing)} {#if !hasMore && rows.empty}
<div class="text-center text-base-content/60 py-12"> <div class="text-center text-base-content/60 py-12">
<p class="text-lg">No releases yet.</p> <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> <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-2.5 shadow-sm"> <div class="flex gap-3 items-center bg-base-100 rounded-box border border-base-300 p-3 shadow-sm">
<img src="https://coverartarchive.org/release-group/{row.mbid}/front" <img src="https://coverartarchive.org/release-group/{row.mbid}/front"
alt="cover" alt="cover"
loading="lazy" loading="lazy"
class="cover-art w-40 h-40 object-cover shrink-0" class="w-16 h-16 rounded object-cover bg-base-200"
onerror="this.onerror=null;this.src='/img/no-cover.svg'"/> onerror="this.onerror=null;this.src='/static/img/no-cover.svg'"/>
<div class="flex-1 min-w-0 flex flex-col justify-center gap-1"> <div class="flex-1 min-w-0">
<div class="font-semibold text-sm text-base-content/70 truncate">{row.artist.name}</div> <div class="flex flex-wrap items-center gap-2">
<a href="{row.mbzUrl}" target="_blank" rel="noopener" <span class="font-semibold truncate">{row.artist.name}</span>
class="link link-hover text-base font-bold leading-tight block truncate">
{row.title}
</a>
<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.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} {#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>
<a href="{row.mbzUrl}" target="_blank" rel="noopener" class="link link-hover text-base-content/90 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> </div>
</div> </div>
@@ -34,23 +34,6 @@
</div> </div>
</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> <button class="btn btn-primary btn-sm w-full">Save</button>
</form> </form>
</div> </div>
+18 -21
View File
@@ -4,37 +4,38 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>DiscDrop</title> <title>DiscDrop</title>
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg"/>
<link rel="alternate" type="application/rss+xml" title="DiscDrop New Releases" href="/rss"/> <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"/> <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://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/[email protected]"></script> <script src="https://unpkg.com/[email protected]"></script>
<link rel="stylesheet" href="/css/app.css"/> <link rel="stylesheet" href="/static/css/app.css"/>
<script src="/js/app.js" defer></script> <script src="/static/js/app.js" defer></script>
</head> </head>
<body> <body>
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300"> <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="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/> <img src="/static/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
<span class="text-xl font-bold">DiscDrop</span> <span class="text-xl font-bold">DiscDrop</span>
</div> </div>
<div id="search-wrap" class="relative flex-1 min-w-[220px] max-w-xl"> <div id="search-wrap" class="relative flex-1 min-w-[220px] max-w-xl">
<input <form id="search-form" class="join w-full" autocomplete="off">
id="search-input" <input
type="text" id="search-input"
name="q" type="search"
placeholder="Search MusicBrainz artists…" name="q"
class="input input-bordered w-full" placeholder="Search MusicBrainz artists…"
autocomplete="off" class="input input-bordered join-item w-full"
hx-get="/search/artists" hx-get="/search/artists"
hx-trigger="input changed delay:300ms" hx-trigger="input changed delay:300ms"
hx-target="#search-dropdown" hx-target="#search-dropdown"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-include="this"/> hx-include="this"/>
</form>
<div id="search-dropdown" <div id="search-dropdown"
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> 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>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -56,10 +57,6 @@
<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> <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> </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> <a href="/artists" class="btn btn-ghost btn-sm" aria-label="Artists">Artists</a>
</div> </div>
</div> </div>