Compare commits

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

After

Width:  |  Height:  |  Size: 277 KiB

@@ -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;
@@ -34,6 +35,9 @@ public class ArtistResource {
@Inject
FeedService feedService;
@Inject
SettingsService settingsService;
@Inject
@Location("fragments/feed-list.html")
Template fragments_feed_list;
@@ -113,7 +117,8 @@ public class ArtistResource {
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)
@@ -3,6 +3,7 @@ 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;
@@ -24,6 +25,9 @@ public class PageResource {
@Inject
SyncService syncService;
@Inject
SettingsService settingsService;
@Inject
Template index;
@@ -38,7 +42,8 @@ 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)
@@ -52,7 +57,8 @@ public class PageResource {
@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())
@@ -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);
@@ -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() {
@@ -15,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");
@@ -42,6 +44,24 @@ public class SettingsService {
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<>();
@@ -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>