Compare commits
4
Commits
508bf77b6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ad3b9d75 | ||
|
|
9e3bd5e916 | ||
|
|
b17a794d4f | ||
|
|
846e27b95f |
@@ -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,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,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](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
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user