Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ad3b9d75 | ||
|
|
9e3bd5e916 | ||
|
|
b17a794d4f | ||
|
|
846e27b95f | ||
|
|
508bf77b6d | ||
|
|
bc0f516bf3 | ||
|
|
7f107420a8 | ||
|
|
2434d7301d | ||
|
|
406d987e94 | ||
|
|
8f6e891bc6 | ||
|
|
9019ead439 | ||
|
|
827e87a8a8 | ||
|
|
4ef83cd701 | ||
|
|
03e5b2810c | ||
|
|
11aba60109 | ||
|
|
4236f7b7f7 | ||
|
|
d7d68b9ab0 | ||
|
|
5861da89b3 |
@@ -0,0 +1,7 @@
|
||||
data/
|
||||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
README.md
|
||||
*.md
|
||||
.DS_Store
|
||||
@@ -0,0 +1,2 @@
|
||||
# Required: MusicBrainz User-Agent (must be a meaningful contact string)
|
||||
MBZ_USER_AGENT=DiscDrop/1.0 ([email protected])
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 277 KiB |
@@ -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 "$@"
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/* DiscDrop custom styles */
|
||||
html { scroll-behavior: smooth; }
|
||||
#search-dropdown:empty { display: none; }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||