Initial commit
Build & Deploy / build (push) Successful in 50s

This commit is contained in:
2026-07-02 15:29:16 +02:00
commit ba82c0b6ff
32 changed files with 2144 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Prevent local build artifacts from leaking into Docker
target/
data/
*.db
# IDE
.idea/
.vscode/
# OS
.DS_Store
# Gitea
.gitea/
+29
View File
@@ -0,0 +1,29 @@
# This workflow builds and deploys 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:
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"
+14
View File
@@ -0,0 +1,14 @@
target/
data/
*.db
*.db-journal
*.db-wal
*.db-shm
*.iml
.idea/
.vscode/
*.class
*.jar
*.log
.DS_Store
.gitea/
+24
View File
@@ -0,0 +1,24 @@
# DiscDrop — Quarkus multi-stage build
# ─── 1. Build ─────────────────────────────────────
FROM maven:3-eclipse-temurin-21-alpine AS build
WORKDIR /build
COPY pom.xml ./
RUN mvn dependency:go-offline -q 2>/dev/null || true
COPY src ./src
RUN mvn package -q -DskipTests
# ─── 2. Runtime ───────────────────────────────────
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN apk add --no-cache sqlite
COPY --from=build /build/target/quarkus-app/ /app/
EXPOSE 8080
VOLUME /app/data
ENV DISC_DROP_DB_PATH=/app/data/discdrop.db
ENV DISC_DROP_CACHE_TTL=8
ENV DISC_DROP_BASE_URL=http://localhost:8080
ENTRYPOINT ["java", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=20", "-jar", "/app/quarkus-run.jar"]
+258
View File
@@ -0,0 +1,258 @@
# DiscDrop
> Self-hosted RSS feed generator for [MusicBrainz](https://musicbrainz.org) release groups.
Track your favourite artists and get a unified RSS feed of their release groups — albums, singles, EPs, and more. No duplicates (release groups, not individual releases), per-artist type monitoring, and a clean modern UI.
Built with **Quarkus + HTMX + daisyUI**.
---
## Features
- **Unified RSS feed** — One feed for all tracked artists, ordered chronologically by first release date
- **Per-artist monitoring** — Choose which release types to track per artist (Album, Single, EP, Broadcast, Other)
- **Artist search** — Autocomplete search against the MusicBrainz API with disambiguation info
- **Release group deduplication** — Tracks release groups instead of individual releases (no duplicate vinyl/CD/digital entries)
- **OPML export** — Subscribe in any RSS reader
- **Theme switching** — daisyUI themes: Dark, Light, Synthwave, Retro, Dracula, Night, Dim, Nord
- **Settings** — Configurable cache TTL (6/12/24h) and default release types
- **Zero infrastructure** — SQLite single-file database, no external services needed
---
## Quick Start
### Prerequisites
- Java 21+
- Maven 3.9+
### Development
```bash
# Run in dev mode with live reload
mvn quarkus:dev
```
The app starts at [http://localhost:8080](http://localhost:8080).
### Production (JAR)
```bash
# Build
mvn clean package
# Run
java -jar target/quarkus-app/quarkus-run.jar
```
### Docker
Multi-stage build — no host pre-build needed, Maven runs inside the container.
```bash
# Build (from project root)
docker build -t discdrop .
# Run
docker run -p 8080:8080 \
-e DISC_DROP_BASE_URL=http://localhost:8080 \
-v discdrop-data:/app/data \
discdrop
### Docker Compose
```bash
docker compose up -d
```
```
---
## Configuration
All configuration is via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `DISC_DROP_DB_PATH` | `data/discdrop.db` | Path to the SQLite database file |
| `DISC_DROP_CACHE_TTL` | `8` | Cache TTL in hours |
| `DISC_DROP_BASE_URL` | `http://localhost:8080` | Public URL for RSS feed links |
| `DISC_DROP_USER_AGENT` | `DiscDrop/1.0 (https://gitea.t3du.com/…)` | User-Agent sent to MusicBrainz API |
The User-Agent header is sent with every MusicBrainz API request per [their etiquette](https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting#Provide_meaningful_User-Agent_strings). It can be changed via `DISC_DROP_USER_AGENT`.
---
## Usage
### 1. Add artists
Use the search bar in the navbar (available on all pages) to find artists. Each result shows the artist name, disambiguation, and country/area. If an artist is already tracked, it shows "✓ Added".
Click **+ Add** to start tracking an artist. DiscDrop immediately fetches their release groups from MusicBrainz and caches them locally.
### 2. Configure monitoring
On the **Artists** page, toggle release type checkboxes per artist:
- **Album** (monitored by default)
- **Single**
- **EP**
- **Broadcast**
- **Other**
Changes take effect immediately. The feed updates on next refresh.
### 3. Subscribe to the feed
On the **Releases** page, use the **RSS Feed** button to get the feed URL:
- RSS: `http://your-instance/feed/rss.xml`
- OPML: `http://your-instance/feed/opml`
The feed includes:
- Release title and artist name
- Cover art from Cover Art Archive
- Release type (Album, Single, etc.)
- First release date
- Link to MusicBrainz release group page
### 4. Settings
Click the ⚙️ icon in the navbar to configure:
- **Cache refresh interval** (6, 8, 12, or 24 hours)
- **Default release types** for newly added artists
---
## Architecture
```
Browser (HTMX + daisyUI) RSS Reader
│ │
│ HTML fragments │ RSS/XML
▼ ▼
┌──────────────────────────────────────┐
│ Quarkus HTTP Server │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Qute │ │ REST │ │ RSS │ │
│ │ Pages │ │ API │ │ Feed │ │
│ └───┬────┘ └───┬────┘ └───┬────┘ │
│ └───────────┼────────────┘ │
│ ▼ │
│ Service Layer │
│ ArtistService · ReleaseGroupService │
│ FeedService · CacheService │
│ │ │ │
│ ┌──────┴──────┐ ┌─────┴──────┐ │
│ │ SQLite │ │ MusicBrainz│ │
│ │ (raw JDBC) │ │ REST │ │
│ └─────────────┘ └────────────┘ │
└──────────────────────────────────────┘
```
### Stack
| Layer | Technology |
|-------|-----------|
| Framework | Quarkus 3.x |
| Language | Java 21 |
| Templating | Qute + HTMX 2.x |
| CSS | daisyUI 4.x (Tailwind CSS) |
| Database | SQLite via raw JDBC |
| RSS | Rome (rometools) |
| Build | Maven |
| Deployment | Docker (JVM) |
---
## API Endpoints
### Pages
| Endpoint | Description |
|----------|-------------|
| `GET /` | Releases feed page |
| `GET /artists` | Tracked artists management page |
### HTMX API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/artists/search?q=` | Artist search autocomplete (HTML fragment) |
| `POST` | `/api/artists/{mbid}/track` | Add artist to tracking |
| `DELETE` | `/api/artists/{mbid}` | Remove artist |
| `PATCH` | `/api/artists/{mbid}/monitor?type=` | Toggle release type monitoring |
| `GET` | `/api/artists/list` | Tracked artists list (HTML fragment) |
| `GET` | `/api/artists/feed-table` | Feed entries table (HTML fragment) |
| `GET` | `/api/settings/form` | Settings form (HTML fragment) |
| `PATCH` | `/api/settings` | Save settings |
### RSS / Export
| Endpoint | Description |
|----------|-------------|
| `GET` | `/feed/rss.xml` | RSS 2.0 feed |
| `GET` | `/feed/opml` | OPML subscription file |
---
## Data Model
Two SQLite tables:
- **`tracked_artist`** — Artists you're tracking with per-type monitoring flags
- **`release_group_cache`** — Cached release groups from MusicBrainz
- **`settings`** — Key-value app settings (cache TTL, default types)
Database file location is configurable via `DISC_DROP_DB_PATH`.
---
## Comparison: mbz-rss-feeder → DiscDrop
| Feature | mbz-rss-feeder (Flask) | DiscDrop (Quarkus) |
|---------|----------------------|-------------------|
| Multiple feeds | Yes | **Single unified feed** |
| Tracks | Individual releases | **Release groups** (no duplicates) |
| Artist search | Simple list | **Autocomplete** with disambiguation |
| Type filtering | None | **Per-artist monitoring** (Album/Single/EP etc.) |
| RSS order | Newest first | **Chronological ascending** by release date |
| Theme | Plain CSS | **daisyUI themes** (7 themes) |
| Cache | File-based | **Database-level TTL cache** |
| Persistence | YAML files | **SQLite** (zero infra) |
---
## Development
### Prerequisites
- Java 21 (Corretto or OpenJDK)
- Apache Maven 3.9+
### Build
```bash
mvn clean package
```
### Run tests
```bash
mvn test
```
### Live reload
```bash
mvn quarkus:dev
```
Changes to Java source and templates are hot-reloaded.
---
## License
MIT
+18
View File
@@ -0,0 +1,18 @@
version: "3.8"
services:
discdrop:
build: .
container_name: discdrop
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- discdrop-data:/app/data
environment:
- DISC_DROP_BASE_URL=http://localhost:8080
- DISC_DROP_CACHE_TTL=8
- DISC_DROP_DB_PATH=/app/data/discdrop.db
- DISC_DROP_USER_AGENT=DiscDrop/1.0 (https://gitea.t3du.com/droideparanoico/discdrop)
volumes:
discdrop-data:
+132
View File
@@ -0,0 +1,132 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.discdrop</groupId>
<artifactId>discdrop</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<quarkus.platform.version>3.37.1</quarkus.platform.version>
<compiler-plugin.version>3.13.0</compiler-plugin.version>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<surefire-plugin.version>3.5.2</surefire-plugin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-bom</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- REST server (resteasy-reactive successor) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy</artifactId>
</dependency>
<!-- Qute templating -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-qute</artifactId>
</dependency>
<!-- REST Client -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-client</artifactId>
</dependency>
<!-- CDI -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<!-- SQLite JDBC -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.49.1.0</version>
</dependency>
<!-- RSS generation -->
<dependency>
<groupId>com.rometools</groupId>
<artifactId>rome</artifactId>
<version>2.1.0</version>
</dependency>
<!-- Jackson for JSON parsing -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-client-jackson</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.5.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<release>21</release>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,43 @@
package com.discdrop.client;
import com.discdrop.client.dto.ArtistDetailsResponse;
import com.discdrop.client.dto.ArtistSearchResponse;
import com.discdrop.client.dto.ReleaseGroupBrowseResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@Path("/")
@RegisterRestClient(configKey = "musicbrainz")
@RegisterProvider(UserAgentFilter.class)
public interface MusicBrainzClient {
@GET
@Path("artist/")
ArtistSearchResponse searchArtists(
@QueryParam("query") String query,
@QueryParam("limit") int limit,
@QueryParam("fmt") String fmt
);
@GET
@Path("artist/{id}")
ArtistDetailsResponse getArtistDetails(
@PathParam("id") String mbid,
@QueryParam("inc") String inc,
@QueryParam("fmt") String fmt
);
@GET
@Path("release-group")
ReleaseGroupBrowseResponse browseReleaseGroups(
@QueryParam("artist") String artistMbid,
@QueryParam("limit") int limit,
@QueryParam("offset") int offset,
@QueryParam("inc") String inc,
@QueryParam("fmt") String fmt
);
}
@@ -0,0 +1,21 @@
package com.discdrop.client;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException;
@Provider
@ApplicationScoped
public class UserAgentFilter implements ClientRequestFilter {
@ConfigProperty(name = "discdrop.user-agent")
String userAgent;
@Override
public void filter(ClientRequestContext requestContext) throws IOException {
requestContext.getHeaders().putSingle("User-Agent", userAgent);
}
}
@@ -0,0 +1,24 @@
package com.discdrop.client.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ArtistDetailsResponse(
String id,
String name,
String disambiguation,
Area area,
String type,
String country,
List<Relation> relations
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Area(String name) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Relation(String type, Url url) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Url(String resource) {}
}
@@ -0,0 +1,23 @@
package com.discdrop.client.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ArtistSearchResponse(
List<Artist> artists,
int count
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Artist(
String id,
String name,
String disambiguation,
Area area,
String type,
String country
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Area(String name) {}
}
@@ -0,0 +1,27 @@
package com.discdrop.client.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ReleaseGroupBrowseResponse(
@JsonProperty("release-groups") List<ReleaseGroup> releaseGroups,
@JsonProperty("release-group-count") int releaseGroupCount
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record ReleaseGroup(
String id,
String title,
@JsonProperty("first-release-date") String firstReleaseDate,
@JsonProperty("primary-type") String primaryType,
@JsonProperty("secondary-types") List<String> secondaryTypes,
@JsonProperty("artist-credit") List<ArtistCredit> artistCredit
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record ArtistCredit(@JsonProperty("artist") Artist artist) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Artist(String id, String name) {}
}
}
}
@@ -0,0 +1,357 @@
package com.discdrop.db;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.sqlite.SQLiteDataSource;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
public class DatabaseService {
private SQLiteDataSource ds;
@ConfigProperty(name = "discdrop.db.path")
String dbPath;
@PostConstruct
void init() throws IOException {
Path dbFile = Path.of(dbPath);
Path parent = dbFile.getParent();
if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent);
}
ds = new SQLiteDataSource();
ds.setUrl("jdbc:sqlite:" + dbPath);
ds.setEnforceForeignKeys(true);
try (Connection conn = ds.getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS tracked_artist (
mbid TEXT PRIMARY KEY,
name TEXT NOT NULL,
disambiguation TEXT NOT NULL DEFAULT '',
area_name TEXT NOT NULL DEFAULT '',
album_monitored INTEGER NOT NULL DEFAULT 1,
single_monitored INTEGER NOT NULL DEFAULT 0,
ep_monitored INTEGER NOT NULL DEFAULT 0,
broadcast_monitored INTEGER NOT NULL DEFAULT 0,
other_monitored INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""");
stmt.execute("""
CREATE TABLE IF NOT EXISTS release_group_cache (
mbid TEXT PRIMARY KEY,
title TEXT NOT NULL,
primary_type TEXT NOT NULL,
secondary_types TEXT NOT NULL DEFAULT '[]',
first_release_date TEXT,
artist_mbid TEXT NOT NULL,
artist_name TEXT NOT NULL,
cached_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_rgc_artist ON release_group_cache(artist_mbid)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_rgc_date ON release_group_cache(first_release_date)");
stmt.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""");
stmt.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('cache_ttl_hours', '8')");
stmt.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('default_types', 'Album')");
} catch (SQLException e) {
throw new RuntimeException("Failed to initialize database", e);
}
}
Connection getConnection() throws SQLException {
return ds.getConnection();
}
// --- tracked_artist DAO ---
public List<Record.TrackedArtist> findAllArtists() {
var artists = new ArrayList<Record.TrackedArtist>();
var sql = "SELECT * FROM tracked_artist ORDER BY name";
try (Connection conn = getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
artists.add(mapArtist(rs));
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return artists;
}
public Optional<Record.TrackedArtist> findByMbid(String mbid) {
var sql = "SELECT * FROM tracked_artist WHERE mbid = ?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, mbid);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return Optional.of(mapArtist(rs));
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return Optional.empty();
}
public boolean exists(String mbid) {
var sql = "SELECT 1 FROM tracked_artist WHERE mbid = ?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, mbid);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public void insertArtist(Record.TrackedArtist a) {
var sql = """
INSERT INTO tracked_artist
(mbid, name, disambiguation, area_name,
album_monitored, single_monitored, ep_monitored,
broadcast_monitored, other_monitored)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, a.mbid());
ps.setString(2, a.name());
ps.setString(3, a.disambiguation());
ps.setString(4, a.areaName());
ps.setInt(5, a.albumMonitored() ? 1 : 0);
ps.setInt(6, a.singleMonitored() ? 1 : 0);
ps.setInt(7, a.epMonitored() ? 1 : 0);
ps.setInt(8, a.broadcastMonitored() ? 1 : 0);
ps.setInt(9, a.otherMonitored() ? 1 : 0);
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public void updateMonitoring(String mbid, Record.MonitoringFlags flags) {
var sql = """
UPDATE tracked_artist SET
album_monitored = ?, single_monitored = ?, ep_monitored = ?,
broadcast_monitored = ?, other_monitored = ?,
updated_at = datetime('now')
WHERE mbid = ?
""";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, flags.album() ? 1 : 0);
ps.setInt(2, flags.single() ? 1 : 0);
ps.setInt(3, flags.ep() ? 1 : 0);
ps.setInt(4, flags.broadcast() ? 1 : 0);
ps.setInt(5, flags.other() ? 1 : 0);
ps.setString(6, mbid);
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public void deleteArtist(String mbid) {
var sql = "DELETE FROM tracked_artist WHERE mbid = ?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, mbid);
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
// --- release_group_cache DAO ---
public void upsertReleaseGroups(List<Record.ReleaseGroup> groups) {
var sql = """
INSERT OR REPLACE INTO release_group_cache
(mbid, title, primary_type, secondary_types,
first_release_date, artist_mbid, artist_name, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
""";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
for (var g : groups) {
ps.setString(1, g.mbid());
ps.setString(2, g.title());
ps.setString(3, g.primaryType());
ps.setString(4, g.secondaryTypes());
ps.setString(5, g.firstReleaseDate());
ps.setString(6, g.artistMbid());
ps.setString(7, g.artistName());
ps.addBatch();
}
ps.executeBatch();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public record FeedResult(List<Record.ReleaseGroup> entries, boolean hasMore) {}
public FeedResult getFeed(List<String> artistMbids, List<String> primaryTypes, int limit, int offset) {
var groups = new ArrayList<Record.ReleaseGroup>();
if (artistMbids.isEmpty()) return new FeedResult(groups, false);
var placeholders = String.join(",", artistMbids.stream().map(m -> "?").toList());
var sql = "SELECT * FROM release_group_cache WHERE artist_mbid IN (" + placeholders + ")";
if (!primaryTypes.isEmpty()) {
var typePlaceholders = String.join(",", primaryTypes.stream().map(t -> "?").toList());
sql += " AND primary_type IN (" + typePlaceholders + ")";
}
sql += " ORDER BY (CASE WHEN first_release_date IS NULL OR first_release_date = '' THEN 1 ELSE 0 END), first_release_date DESC";
boolean useLimit = limit > 0;
if (useLimit) sql += " LIMIT ? OFFSET ?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
int idx = 1;
for (var mbid : artistMbids) ps.setString(idx++, mbid);
for (var type : primaryTypes) ps.setString(idx++, type);
if (useLimit) {
ps.setInt(idx++, limit + 1);
ps.setInt(idx, offset);
}
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
groups.add(mapReleaseGroup(rs));
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
boolean hasMore = useLimit && groups.size() > limit;
if (hasMore) groups.remove(groups.size() - 1);
return new FeedResult(groups, hasMore);
}
public void deleteReleaseGroupsForArtist(String mbid) {
var sql = "DELETE FROM release_group_cache WHERE artist_mbid = ?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, mbid);
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public long countReleaseGroupsForArtist(String mbid) {
var sql = "SELECT COUNT(*) FROM release_group_cache WHERE artist_mbid = ?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, mbid);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) return rs.getLong(1);
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return 0;
}
public Optional<Instant> getLastCachedAt(String artistMbid) {
var sql = "SELECT MAX(cached_at) FROM release_group_cache WHERE artist_mbid = ?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, artistMbid);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next() && rs.getString(1) != null) {
return Optional.of(Instant.parse(rs.getString(1).replace(" ", "T") + "Z"));
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return Optional.empty();
}
// --- settings DAO ---
public String getSetting(String key, String defaultValue) {
var sql = "SELECT value FROM settings WHERE key = ?";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, key);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) return rs.getString("value");
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return defaultValue;
}
public void setSetting(String key, String value) {
var sql = "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, key);
ps.setString(2, value);
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
// --- mappers ---
private Record.TrackedArtist mapArtist(ResultSet rs) throws SQLException {
return new Record.TrackedArtist(
rs.getString("mbid"),
rs.getString("name"),
rs.getString("disambiguation"),
rs.getString("area_name"),
rs.getInt("album_monitored") == 1,
rs.getInt("single_monitored") == 1,
rs.getInt("ep_monitored") == 1,
rs.getInt("broadcast_monitored") == 1,
rs.getInt("other_monitored") == 1,
parseInstant(rs.getString("created_at")),
parseInstant(rs.getString("updated_at"))
);
}
private Record.ReleaseGroup mapReleaseGroup(ResultSet rs) throws SQLException {
return new Record.ReleaseGroup(
rs.getString("mbid"),
rs.getString("title"),
rs.getString("primary_type"),
rs.getString("secondary_types"),
rs.getString("first_release_date"),
rs.getString("artist_mbid"),
rs.getString("artist_name"),
parseInstant(rs.getString("cached_at"))
);
}
private Instant parseInstant(String ts) {
if (ts == null || ts.isBlank()) return null;
return Instant.parse(ts.replace(" ", "T") + "Z");
}
}
+41
View File
@@ -0,0 +1,41 @@
package com.discdrop.db;
import java.time.Instant;
public final class Record {
private Record() {}
public record TrackedArtist(
String mbid,
String name,
String disambiguation,
String areaName,
boolean albumMonitored,
boolean singleMonitored,
boolean epMonitored,
boolean broadcastMonitored,
boolean otherMonitored,
Instant createdAt,
Instant updatedAt
) {}
public record MonitoringFlags(
boolean album,
boolean single,
boolean ep,
boolean broadcast,
boolean other
) {}
public record ReleaseGroup(
String mbid,
String title,
String primaryType,
String secondaryTypes,
String firstReleaseDate,
String artistMbid,
String artistName,
Instant cachedAt
) {}
}
@@ -0,0 +1,113 @@
package com.discdrop.service;
import com.discdrop.client.MusicBrainzClient;
import com.discdrop.client.dto.ArtistDetailsResponse;
import com.discdrop.client.dto.ArtistSearchResponse;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.MonitoringFlags;
import com.discdrop.db.Record.TrackedArtist;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@ApplicationScoped
public class ArtistService {
@Inject
@RestClient
MusicBrainzClient mbClient;
@Inject
DatabaseService db;
public record SearchResult(String mbid, String name, String disambiguation,
String area, String type, String country,
boolean alreadyTracked) {}
public List<SearchResult> search(String query) {
var resp = mbClient.searchArtists(
"artist:" + query, 10, "json");
if (resp == null || resp.artists() == null) return List.of();
var results = new ArrayList<SearchResult>();
for (var a : resp.artists()) {
results.add(new SearchResult(
a.id(),
a.name(),
a.disambiguation() != null ? a.disambiguation() : "",
a.area() != null ? a.area().name() : (a.country() != null ? a.country() : ""),
a.type() != null ? a.type() : "",
a.country() != null ? a.country() : "",
db.exists(a.id())
));
}
return results;
}
public Optional<TrackedArtist> findByMbid(String mbid) {
return db.findByMbid(mbid);
}
public List<TrackedArtist> findAll() {
return db.findAllArtists();
}
public TrackedArtist addArtist(String mbid) {
ArtistDetailsResponse details = mbClient.getArtistDetails(mbid, "url-rels", "json");
if (details == null) {
throw new RuntimeException("Artist not found: " + mbid);
}
var defaultTypes = db.getSetting("default_types", "Album");
var flags = parseDefaultTypes(defaultTypes);
var artist = new TrackedArtist(
details.id(),
details.name(),
details.disambiguation() != null ? details.disambiguation() : "",
details.area() != null ? details.area().name() : "",
flags.album(),
flags.single(),
flags.ep(),
flags.broadcast(),
flags.other(),
Instant.now(),
Instant.now()
);
db.insertArtist(artist);
return artist;
}
public void removeArtist(String mbid) {
db.deleteReleaseGroupsForArtist(mbid);
db.deleteArtist(mbid);
}
public void updateMonitoring(String mbid, MonitoringFlags flags) {
db.updateMonitoring(mbid, flags);
}
private MonitoringFlags parseDefaultTypes(String types) {
boolean album = false, single = false, ep = false,
broadcast = false, other = false;
if (types == null || types.isBlank()) {
album = true;
return new MonitoringFlags(album, single, ep, broadcast, other);
}
for (var t : types.split(",")) {
switch (t.trim().toLowerCase()) {
case "album" -> album = true;
case "single" -> single = true;
case "ep" -> ep = true;
case "broadcast" -> broadcast = true;
case "other" -> other = true;
}
}
return new MonitoringFlags(album, single, ep, broadcast, other);
}
}
@@ -0,0 +1,36 @@
package com.discdrop.service;
import com.discdrop.db.DatabaseService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
@ApplicationScoped
public class CacheService {
@Inject
DatabaseService db;
public long getTtlHours() {
var ttl = db.getSetting("cache_ttl_hours", "8");
try {
return Long.parseLong(ttl);
} catch (NumberFormatException e) {
return 8;
}
}
public boolean isStale(String artistMbid) {
Optional<Instant> lastCached = db.getLastCachedAt(artistMbid);
if (lastCached.isEmpty()) return true;
long ttlHours = getTtlHours();
return lastCached.get().plus(ttlHours, ChronoUnit.HOURS).isBefore(Instant.now());
}
public boolean needsRefresh(String artistMbid) {
return db.countReleaseGroupsForArtist(artistMbid) == 0 || isStale(artistMbid);
}
}
@@ -0,0 +1,92 @@
package com.discdrop.service;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.DatabaseService.FeedResult;
import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.db.Record.TrackedArtist;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.List;
@ApplicationScoped
public class FeedService {
@Inject
ArtistService artistService;
@Inject
ReleaseGroupService releaseGroupService;
@Inject
CacheService cacheService;
@Inject
DatabaseService db;
public static final int PAGE_SIZE = 10;
public List<ReleaseGroup> getAllFeedEntries() {
var artists = artistService.findAll();
if (artists.isEmpty()) return List.of();
var artistMbids = artistMbidsWithRefresh(artists);
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
var allEntries = db.getFeed(artistMbids, allTypes, 0, 0).entries();
return filterByArtistMonitoring(allEntries, artists);
}
public FeedResult getFeedEntries(int page) {
var artists = artistService.findAll();
if (artists.isEmpty()) return new FeedResult(List.of(), false);
var artistMbids = artistMbidsWithRefresh(artists);
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
var allEntries = db.getFeed(artistMbids, allTypes, 0, 0).entries();
var filtered = filterByArtistMonitoring(allEntries, artists);
var fromIndex = (page - 1) * PAGE_SIZE;
if (fromIndex >= filtered.size()) return new FeedResult(List.of(), false);
var toIndex = Math.min(fromIndex + PAGE_SIZE, filtered.size());
var pageEntries = filtered.subList(fromIndex, toIndex);
var hasMore = toIndex < filtered.size();
return new FeedResult(pageEntries, hasMore);
}
private List<String> artistMbidsWithRefresh(List<TrackedArtist> artists) {
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
var mbids = new ArrayList<String>();
for (var a : artists) {
if (cacheService.needsRefresh(a.mbid())) {
releaseGroupService.fetchAndCache(a.mbid(), a.name(), allTypes);
}
mbids.add(a.mbid());
}
return mbids;
}
private List<ReleaseGroup> filterByArtistMonitoring(List<ReleaseGroup> entries, List<TrackedArtist> artists) {
var flags = new java.util.HashMap<String, java.util.Set<String>>();
for (var a : artists) {
flags.put(a.mbid(), java.util.Set.copyOf(getMonitoredTypes(a)));
}
var result = new ArrayList<ReleaseGroup>();
for (var e : entries) {
var artistFlags = flags.get(e.artistMbid());
if (artistFlags != null && artistFlags.contains(e.primaryType())) {
result.add(e);
}
}
return result;
}
private List<String> getMonitoredTypes(TrackedArtist a) {
var types = new ArrayList<String>();
if (a.albumMonitored()) types.add("Album");
if (a.singleMonitored()) types.add("Single");
if (a.epMonitored()) types.add("EP");
if (a.broadcastMonitored()) types.add("Broadcast");
if (a.otherMonitored()) types.add("Other");
return types;
}
}
@@ -0,0 +1,90 @@
package com.discdrop.service;
import com.discdrop.client.MusicBrainzClient;
import com.discdrop.client.dto.ReleaseGroupBrowseResponse;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.ReleaseGroup;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@ApplicationScoped
public class ReleaseGroupService {
private static final int MAX_RELEASE_GROUPS = 500;
private static final int PAGE_SIZE = 100;
@Inject
@RestClient
MusicBrainzClient mbClient;
@Inject
DatabaseService db;
@Inject
ObjectMapper json;
public List<ReleaseGroup> fetchAndCache(String artistMbid, String artistName,
List<String> monitoredTypes) {
var allGroups = new ArrayList<ReleaseGroup>();
int offset = 0;
int total = Integer.MAX_VALUE;
while (offset < total && offset < MAX_RELEASE_GROUPS) {
ReleaseGroupBrowseResponse page = mbClient.browseReleaseGroups(
artistMbid, PAGE_SIZE, offset, "url-rels+artist-credits", "json");
if (page == null || page.releaseGroups() == null) break;
total = Math.min(page.releaseGroupCount(), MAX_RELEASE_GROUPS);
for (var rg : page.releaseGroups()) {
String primaryType = rg.primaryType() != null ? rg.primaryType() : "Other";
String secondaryTypes = "[]";
if (rg.secondaryTypes() != null && !rg.secondaryTypes().isEmpty()) {
try {
secondaryTypes = json.writeValueAsString(rg.secondaryTypes());
} catch (Exception e) {
secondaryTypes = "[]";
}
}
String rgArtistName = artistName;
if (rg.artistCredit() != null && !rg.artistCredit().isEmpty()) {
rgArtistName = rg.artistCredit().get(0).artist().name();
}
allGroups.add(new ReleaseGroup(
rg.id(),
rg.title(),
primaryType,
secondaryTypes,
rg.firstReleaseDate(),
artistMbid,
rgArtistName,
Instant.now()
));
}
offset += PAGE_SIZE;
if (offset < total) {
try { Thread.sleep(1100); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
if (!allGroups.isEmpty()) {
db.upsertReleaseGroups(allGroups);
}
return allGroups;
}
}
@@ -0,0 +1,123 @@
package com.discdrop.util;
import com.discdrop.db.Record.ReleaseGroup;
import com.rometools.rome.feed.synd.*;
import com.rometools.rome.io.SyndFeedOutput;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@ApplicationScoped
public class RSSFeedBuilder {
@ConfigProperty(name = "discdrop.base-url")
String baseUrl;
public String buildRss(List<ReleaseGroup> entries) {
var feed = new SyndFeedImpl();
feed.setFeedType("rss_2.0");
feed.setTitle("DiscDrop — Release Feed");
feed.setLink(baseUrl);
feed.setDescription("Latest music release groups from your tracked artists");
var selfLink = new SyndLinkImpl();
selfLink.setHref(baseUrl + "/feed/rss.xml");
selfLink.setRel("self");
selfLink.setType("application/rss+xml");
feed.setLinks(List.of(selfLink));
var items = new ArrayList<SyndEntry>();
for (var rg : entries) {
var item = new SyndEntryImpl();
item.setTitle(rg.artistName() + "" + rg.title());
item.setLink("https://musicbrainz.org/release-group/" + rg.mbid());
item.setUri("mbz-rg-" + rg.mbid());
var desc = new SyndContentImpl();
desc.setType("text/html");
desc.setValue(buildDescription(rg));
item.setDescription(desc);
item.setPublishedDate(parseDate(rg.firstReleaseDate()));
var category = new SyndCategoryImpl();
category.setName(rg.primaryType());
item.setCategories(List.of(category));
items.add(item);
}
feed.setEntries(items);
feed.setPublishedDate(new Date());
try {
var output = new SyndFeedOutput();
return output.outputString(feed);
} catch (Exception e) {
throw new RuntimeException("Failed to generate RSS feed", e);
}
}
public String buildOpml() {
return """
<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>DiscDrop — Release Feed</title>
</head>
<body>
<outline text="DiscDrop Releases" type="rss"
xmlUrl="%s/feed/rss.xml" htmlUrl="%s"/>
</body>
</opml>
""".formatted(baseUrl, baseUrl);
}
private String buildDescription(ReleaseGroup rg) {
var sb = new StringBuilder();
sb.append("<img src=\"https://coverartarchive.org/release-group/")
.append(rg.mbid()).append("/front\" alt=\"Cover\">");
sb.append("<p><b>").append(escHtml(rg.artistName())).append("</b>")
.append(" <i>").append(escHtml(rg.title())).append("</i><br>");
sb.append("Type: ").append(escHtml(rg.primaryType()));
if (rg.secondaryTypes() != null && !rg.secondaryTypes().equals("[]")) {
sb.append(" | Secondary: ").append(escHtml(rg.secondaryTypes()));
}
sb.append("<br>First Release Date: ").append(rg.firstReleaseDate());
sb.append("</p>");
return sb.toString();
}
private Date parseDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) return new Date();
try {
ZonedDateTime zdt;
if (dateStr.length() == 4) {
zdt = ZonedDateTime.of(
Integer.parseInt(dateStr), 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"));
} else if (dateStr.length() == 7) {
var parts = dateStr.split("-");
zdt = ZonedDateTime.of(
Integer.parseInt(parts[0]), Integer.parseInt(parts[1]),
1, 0, 0, 0, 0, ZoneId.of("UTC"));
} else {
zdt = ZonedDateTime.parse(dateStr + "T00:00:00Z");
}
return Date.from(zdt.toInstant());
} catch (Exception e) {
return new Date();
}
}
private String escHtml(String s) {
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,150 @@
package com.discdrop.web;
import com.discdrop.db.DatabaseService.FeedResult;
import com.discdrop.db.Record.MonitoringFlags;
import com.discdrop.db.Record.TrackedArtist;
import com.discdrop.service.ArtistService;
import com.discdrop.service.ArtistService.SearchResult;
import com.discdrop.service.FeedService;
import com.discdrop.service.ReleaseGroupService;
import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
@Path("/api/artists")
public class ArtistController {
@Inject
ArtistService artistService;
@Inject
ReleaseGroupService releaseGroupService;
@Inject
FeedService feedService;
@Inject
@Location("fragments/search-results")
Template searchResultsTemplate;
@Inject
@Location("fragments/artist-list")
Template artistListTemplate;
@Inject
@Location("fragments/feed-entries")
Template feedEntriesTemplate;
@GET
@Path("/search")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance search(@QueryParam("q") String q) {
List<SearchResult> results;
if (q == null || q.isBlank()) {
results = List.of();
} else {
results = artistService.search(q);
}
return searchResultsTemplate.data("artists", results);
}
@POST
@Path("/{mbid}/track")
@Produces(MediaType.TEXT_HTML)
public Response trackArtist(@PathParam("mbid") String mbid) {
var artist = artistService.addArtist(mbid);
releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), List.of("Album", "Single", "EP", "Broadcast", "Other"));
var allArtists = artistService.findAll();
var listHtml = artistListTemplate.data("artists", allArtists).render();
return Response.ok(listHtml + feedReloadScript()).build();
}
@DELETE
@Path("/{mbid}")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance removeArtist(@PathParam("mbid") String mbid) {
artistService.removeArtist(mbid);
var allArtists = artistService.findAll();
return artistListTemplate.data("artists", allArtists);
}
@DELETE
@Path("/{mbid}/from-search")
@Produces(MediaType.TEXT_HTML)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response removeArtistFromSearch(@PathParam("mbid") String mbid,
@QueryParam("q") @DefaultValue("") String q,
@FormParam("q") @DefaultValue("") String qForm) {
artistService.removeArtist(mbid);
var query = q != null && !q.isBlank() ? q : qForm;
List<SearchResult> results;
if (query.isBlank()) {
results = List.of();
} else {
results = artistService.search(query);
}
var searchHtml = searchResultsTemplate.data("artists", results).render();
var allArtists = artistService.findAll();
var listHtml = artistListTemplate.data("artists", allArtists).render();
return Response.ok(searchHtml + listHtml + feedReloadScript()).build();
}
@PATCH
@Path("/{mbid}/monitor")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance updateMonitoring(
@PathParam("mbid") String mbid,
@QueryParam("type") String type) {
var existing = artistService.findByMbid(mbid)
.orElseThrow(() -> new NotFoundException("Artist not found"));
var flags = new MonitoringFlags(
type.equals("album") ? !existing.albumMonitored() : existing.albumMonitored(),
type.equals("single") ? !existing.singleMonitored() : existing.singleMonitored(),
type.equals("ep") ? !existing.epMonitored() : existing.epMonitored(),
type.equals("broadcast") ? !existing.broadcastMonitored() : existing.broadcastMonitored(),
type.equals("other") ? !existing.otherMonitored() : existing.otherMonitored()
);
artistService.updateMonitoring(mbid, flags);
var allArtists = artistService.findAll();
return artistListTemplate.data("artists", allArtists);
}
@GET
@Path("/list")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance artistList() {
var artists = artistService.findAll();
return artistListTemplate.data("artists", artists);
}
@GET
@Path("/feed-table")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance feedTable(@QueryParam("page") @DefaultValue("1") int page) {
var result = feedService.getFeedEntries(page);
return feedEntriesTemplate
.data("entries", result.entries())
.data("hasMore", result.hasMore())
.data("nextPage", page + 1);
}
private List<String> getMonitoredTypes(TrackedArtist a) {
var types = new ArrayList<String>();
if (a.albumMonitored()) types.add("Album");
if (a.singleMonitored()) types.add("Single");
if (a.epMonitored()) types.add("EP");
if (a.broadcastMonitored()) types.add("Broadcast");
if (a.otherMonitored()) types.add("Other");
return types;
}
private String feedReloadScript() {
return "<script>setTimeout(function(){var e=document.getElementById('feed-entries');if(e)htmx.ajax('GET','/api/artists/feed-table',{target:'#feed-entries',swap:'innerHTML'})},200)</" + "script>";
}
}
@@ -0,0 +1,22 @@
package com.discdrop.web;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/artists")
public class ArtistsPage {
@Inject
Template artists;
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance index() {
return artists.data("activeTab", "artists");
}
}
@@ -0,0 +1,37 @@
package com.discdrop.web;
import com.discdrop.service.FeedService;
import com.discdrop.util.RSSFeedBuilder;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/feed")
public class FeedController {
@Inject
FeedService feedService;
@Inject
RSSFeedBuilder feedBuilder;
@GET
@Path("/rss.xml")
@Produces(MediaType.APPLICATION_XML)
public Response rssFeed() {
var entries = feedService.getAllFeedEntries();
var xml = feedBuilder.buildRss(entries);
return Response.ok(xml, MediaType.APPLICATION_XML).build();
}
@GET
@Path("/opml")
@Produces(MediaType.APPLICATION_XML)
public Response opml() {
var xml = feedBuilder.buildOpml();
return Response.ok(xml, MediaType.APPLICATION_XML).build();
}
}
@@ -0,0 +1,22 @@
package com.discdrop.web;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/")
public class ReleasesPage {
@Inject
Template releases;
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance index() {
return releases.data("activeTab", "releases");
}
}
@@ -0,0 +1,44 @@
package com.discdrop.web;
import com.discdrop.db.DatabaseService;
import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
@Path("/api/settings")
public class SettingsController {
@Inject
DatabaseService db;
@Inject
@Location("fragments/settings-form")
Template settingsFormTemplate;
@GET
@Path("/form")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance settingsForm() {
var cacheTtl = db.getSetting("cache_ttl_hours", "8");
var defaultTypes = db.getSetting("default_types", "Album");
return settingsFormTemplate.data("cacheTtl", cacheTtl)
.data("defaultTypes", defaultTypes);
}
@PATCH
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_HTML)
public Response saveSettings(
@FormParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl,
@FormParam("default_types") List<String> defaultTypes) {
db.setSetting("cache_ttl_hours", cacheTtl);
db.setSetting("default_types", defaultTypes != null ? String.join(",", defaultTypes) : "Album");
return Response.ok("<div class=\"alert alert-success\">Settings saved</div>")
.build();
}
}
+20
View File
@@ -0,0 +1,20 @@
# Server
quarkus.http.port=8080
# MusicBrainz API
quarkus.rest-client."com.discdrop.client.MusicBrainzClient".url=https://musicbrainz.org/ws/2
# Qute templating
quarkus.qute.suffixes=html
quarkus.qute.content-types.html=text/html
# Jackson
quarkus.jackson.fail-on-unknown-properties=false
# DiscDrop settings
discdrop.db.path=${DISC_DROP_DB_PATH:data/discdrop.db}
discdrop.cache.ttl-hours=${DISC_DROP_CACHE_TTL:8}
discdrop.base-url=${DISC_DROP_BASE_URL:http://localhost:8080}
# User-Agent sent to MusicBrainz API (env: DISC_DROP_USER_AGENT)
discdrop.user-agent=${DISC_DROP_USER_AGENT:DiscDrop/0.1 (https://https://github.com/droideparanoico/discdrop)}
@@ -0,0 +1,9 @@
{#include layouts/base}
{#page-content}
<div class="w-full">
<h2 class="text-2xl font-bold mb-5">🎤 Tracked Artists</h2>
<div id="artist-list" hx-get="/api/artists/list" hx-trigger="load">
<span class="loading loading-spinner loading-lg block mx-auto my-12"></span>
</div>
</div>
{/page-content}
@@ -0,0 +1,90 @@
<div id="artist-list" hx-swap-oob="true">
{#if artists.isEmpty()}
<div class="bg-base-200" style="border:1px solid var(--fallback-b3,oklch(var(--b3)/1));border-radius:0.5rem;padding:2.5rem;text-align:center">
<svg class="icon-lg text-base-content/20" style="margin:0 auto 0.75rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<p class="text-lg font-medium text-base-content/60">No artists tracked yet</p>
<p class="text-sm text-base-content/40 mt-2">Search for artists using the search bar above</p>
</div>
{#else}
<div class="overflow-x-auto rounded-box border border-base-300">
<table class="table table-pin-rows">
<thead>
<tr style="background:var(--fallback-b2,oklch(var(--b2)/1))">
<th>Artist</th>
<th>Since</th>
<th>Release Types</th>
<th style="width:6rem">Remove</th>
</tr>
</thead>
<tbody>
{#for artist in artists}
<tr style="transition:background 0.15s" onmouseover="this.style.background='var(--fallback-b2,oklch(var(--b2)/1))'" onmouseout="this.style.background=''">
<td>
<a href="https://musicbrainz.org/artist/{artist.mbid}" target="_blank" rel="noopener noreferrer" style="font-weight:500;text-decoration:none;color:inherit">{artist.name}</a>
{#if artist.disambiguation}
<div class="text-xs text-base-content/50">{artist.disambiguation}</div>
{/if}
</td>
<td class="text-sm text-base-content/50">{artist.createdAt}</td>
<td>
<div style="display:flex;flex-wrap:wrap;gap:0.375rem">
<label class="label cursor-pointer gap-1 p-1">
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary"
{#if artist.albumMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=album"
hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs" style="color:var(--p)">Album</span>
</label>
<label class="label cursor-pointer gap-1 p-1">
<input type="checkbox" class="checkbox checkbox-xs checkbox-secondary"
{#if artist.singleMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=single"
hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs" style="color:var(--s)">Single</span>
</label>
<label class="label cursor-pointer gap-1 p-1">
<input type="checkbox" class="checkbox checkbox-xs checkbox-accent"
{#if artist.epMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=ep"
hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs" style="color:var(--a)">EP</span>
</label>
<label class="label cursor-pointer gap-1 p-1">
<input type="checkbox" class="checkbox checkbox-xs"
{#if artist.broadcastMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=broadcast"
hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs text-base-content/60">Broadcast</span>
</label>
<label class="label cursor-pointer gap-1 p-1">
<input type="checkbox" class="checkbox checkbox-xs"
{#if artist.otherMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=other"
hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs text-base-content/60">Other</span>
</label>
</div>
</td>
<td>
<button class="btn btn-ghost btn-sm btn-square" style="color:var(--er)"
hx-delete="/api/artists/{artist.mbid}"
hx-target="#artist-list"
hx-swap="innerHTML"
onclick="return confirm('Remove {artist.name}?')">
<svg class="icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</td>
</tr>
{/for}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,46 @@
{#if entries.isEmpty()}
<div class="card bg-base-200 shadow-xl text-center" style="padding:3rem">
<p class="text-lg text-base-content/60">No releases found</p>
<p class="text-sm text-base-content/40 mt-2">Add artists to your collection to see their releases here</p>
</div>
{#else}
{#for entry in entries}
<a href="https://musicbrainz.org/release-group/{entry.mbid}" target="_blank" rel="noopener noreferrer"
class="card card-side bg-base-200 border border-base-300 hover:bg-base-300 card-shadow overflow-hidden">
<figure class="w-64">
<img src="https://coverartarchive.org/release-group/{entry.mbid}/front-250"
alt="{entry.title}" class="object-cover w-full h-full"
loading="lazy"
onerror="this.parentElement.innerHTML='<div class=\'flex items-center justify-center h-full text-base-content/20\'>No Cover</div>'">
</figure>
<div class="card-body">
<h3 style="font-size:1.5rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--fallback-bc,oklch(var(--bc)/0.6))">{entry.artistName}</h3>
<h2 style="font-size:2rem;font-weight:700">{entry.title}</h2>
<div style="margin-top:0.75rem">
{#if entry.firstReleaseDate}
<div style="font-size:1.5rem;color:var(--fallback-bc,oklch(var(--bc)/0.4))">{entry.firstReleaseDate}</div>
{/if}
<div style="margin-top:0.4rem">
{#if entry.primaryType == 'Album'}
<span class="badge badge-primary" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">Album</span>
{#else if entry.primaryType == 'Single'}
<span class="badge badge-secondary" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">Single</span>
{#else if entry.primaryType == 'EP'}
<span class="badge badge-accent" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">EP</span>
{#else}
<span class="badge badge-ghost" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">{entry.primaryType}</span>
{/if}
</div>
</div>
</div>
</a>
{/for}
{#if hasMore}
<div style="display:flex;justify-content:center;padding:1rem 0">
<button class="btn btn-primary btn-sm"
hx-get="/api/artists/feed-table?page={nextPage}"
hx-target="closest div"
hx-swap="outerHTML">Load more</button>
</div>
{/if}
{/if}
@@ -0,0 +1,40 @@
<div class="card bg-base-300 dropdown-shadow border border-base-300 mt-1" style="max-height:18rem;overflow-y:auto">
{#if artists.isEmpty()}
<div style="padding:1rem;text-align:center;font-size:0.875rem;color:var(--fallback-bc,oklch(var(--bc)/0.5))">No artists found</div>
{#else}
{#for artist in artists}
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem;border-bottom:1px solid var(--fallback-b3,oklch(var(--b3)/1));transition:background 0.1s"
onmouseover="this.style.background='var(--fallback-b2,oklch(var(--b2)/1))'" onmouseout="this.style.background=''">
<div style="flex:1;min-width:0;margin-right:0.5rem">
<div style="display:flex;align-items:center;gap:0.5rem">
<span style="font-weight:600;font-size:0.9rem">{artist.name}</span>
{#if artist.type}
<span class="badge badge-ghost badge-xs">{artist.type}</span>
{/if}
</div>
{#if artist.disambiguation}
<div style="font-size:0.75rem;color:var(--fallback-bc,oklch(var(--bc)/0.5));overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{artist.disambiguation}</div>
{/if}
{#if artist.area}
<div style="font-size:0.75rem;color:var(--fallback-bc,oklch(var(--bc)/0.4))">{artist.area}</div>
{/if}
</div>
<div class="flex-shrink-0">
{#if artist.alreadyTracked}
<button class="btn btn-warning btn-sm"
hx-delete="/api/artists/{artist.mbid}/from-search"
hx-include="#search-input"
hx-target="#search-results"
hx-swap="innerHTML">✓ Added</button>
{#else}
<button class="btn btn-primary btn-sm"
hx-post="/api/artists/{artist.mbid}/track"
hx-target="#search-results"
hx-swap="innerHTML"
onclick="document.getElementById('search-input').value=''">+ Add</button>
{/if}
</div>
</div>
{/for}
{/if}
</div>
@@ -0,0 +1,70 @@
<h3 class="font-bold text-lg mb-4">⚙️ Settings</h3>
<form hx-patch="/api/settings"
hx-target="#settings-modal-content"
hx-swap="innerHTML">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Refresh releases every</span>
</label>
<div class="flex gap-4">
<label class="label cursor-pointer gap-2">
<input type="radio" name="cache_ttl_hours" value="6" class="radio radio-primary radio-sm"
{#if cacheTtl == '6'}checked{/if}>
<span class="label-text">6 hours</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="radio" name="cache_ttl_hours" value="8" class="radio radio-primary radio-sm"
{#if cacheTtl == '8'}checked{/if}>
<span class="label-text">8 hours</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="radio" name="cache_ttl_hours" value="12" class="radio radio-primary radio-sm"
{#if cacheTtl == '12'}checked{/if}>
<span class="label-text">12 hours</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="radio" name="cache_ttl_hours" value="24" class="radio radio-primary radio-sm"
{#if cacheTtl == '24'}checked{/if}>
<span class="label-text">24 hours</span>
</label>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Default Release Types (when adding artists)</span>
</label>
<div class="flex flex-wrap gap-4">
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="default_types" value="Album" class="checkbox checkbox-sm"
{#if defaultTypes.contains('Album')}checked{/if}>
<span class="label-text">Album</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="default_types" value="Single" class="checkbox checkbox-sm"
{#if defaultTypes.contains('Single')}checked{/if}>
<span class="label-text">Single</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="default_types" value="EP" class="checkbox checkbox-sm"
{#if defaultTypes.contains('EP')}checked{/if}>
<span class="label-text">EP</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="default_types" value="Broadcast" class="checkbox checkbox-sm"
{#if defaultTypes.contains('Broadcast')}checked{/if}>
<span class="label-text">Broadcast</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="default_types" value="Other" class="checkbox checkbox-sm"
{#if defaultTypes.contains('Other')}checked{/if}>
<span class="label-text">Other</span>
</label>
</div>
</div>
<div class="modal-action">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="settingsModal.close()">Cancel</button>
</div>
</form>
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DiscDrop</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2/dist/htmx.min.js"></script>
<style>
body {
width: 100vw;
overflow-x: hidden;
}
#search-wrapper { position: relative; width: 100%; }
#search-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; }
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem; }
.icon-sm { width: 16px; height: 16px; flex-shrink: 0; }
.icon-xs { width: 12px; height: 12px; flex-shrink: 0; }
.icon-lg { width: 48px; height: 48px; flex-shrink: 0; }
.card-shadow { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.card-shadow:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.25); }
.dropdown-shadow { box-shadow: 0 8px 24px rgba(0,0,0,0.3); }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
var saved = localStorage.getItem('daisyui-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
document.addEventListener('click', function (e) {
var search = document.getElementById('search-wrapper');
var results = document.getElementById('search-results');
if (results && search && !search.contains(e.target)) {
results.innerHTML = '';
}
});
});
function setTheme(sel) {
var theme = sel.value;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('daisyui-theme', theme);
}
</script>
</head>
<body class="min-h-screen bg-base-100">
<div class="navbar bg-base-300 shadow-lg px-4 min-h-0 h-14">
<div class="navbar-start"></div>
<div class="navbar-center gap-4">
<a href="/" class="btn btn-ghost btn-sm no-underline">DiscDrop</a>
<a href="/artists" class="btn btn-ghost btn-sm {#if activeTab == 'artists'}btn-active{/if}">Artists</a>
<div id="search-wrapper" style="width:24rem">
<form hx-get="/api/artists/search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-trigger="keyup changed delay:300ms from:#search-input">
<input id="search-input" type="text" placeholder="Search artist to follow"
class="input input-bordered input-sm" name="q" autocomplete="off" style="width:100%">
</form>
<div id="search-results"></div>
</div>
</div>
<div class="navbar-end gap-2">
<select class="select select-bordered select-sm" style="width:7rem" onchange="setTheme(this)">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="synthwave">Synthwave</option>
<option value="retro">Retro</option>
<option value="dracula">Dracula</option>
<option value="night">Night</option>
<option value="dim">Dim</option>
<option value="nord">Nord</option>
</select>
<button class="btn btn-ghost btn-sm" onclick="settingsModal.showModal()">⚙️</button>
</div>
</div>
<main>
{#insert page-content}{/insert}
</main>
<dialog id="settingsModal" class="modal">
<div class="modal-box max-w-md" id="settings-modal-content"
hx-get="/api/settings/form" hx-trigger="load" hx-swap="innerHTML">
<span class="loading loading-spinner"></span>
</div>
</dialog>
</body>
</html>
@@ -0,0 +1,26 @@
{#include layouts/base}
{#page-content}
<div class="w-full">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;flex-wrap:wrap;gap:0.75rem">
<h2 style="font-size:1.5rem;font-weight:700">📡 Releases</h2>
<div style="display:flex;gap:0.5rem">
<a href="/feed/rss.xml" class="btn btn-primary btn-sm gap-2">
<svg class="icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/>
<circle cx="5" cy="19" r="1"/>
</svg>
RSS Feed
</a>
<a href="/feed/opml" class="btn btn-ghost btn-sm gap-2">
<svg class="icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
OPML
</a>
</div>
</div>
<div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load" style="display:flex;flex-direction:column;gap:2.5rem">
<div style="display:flex;justify-content:center;padding:4rem 0">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
{/page-content}