Phase 1: Project scaffold

- Quarkus Maven project with all dependencies
- SQLite DatabaseService with DAO methods
- MusicBrainz REST client with DTOs
- ArtistService, ReleaseGroupService, CacheService, FeedService
- HTMX web controllers (ReleasesPage, ArtistsPage, ArtistController, FeedController, SettingsController)
- RSS feed builder using Rome library
- Qute templates with daisyUI theme switching
- Base layout with navbar, search, theme picker, settings modal
This commit is contained in:
droideparanoico
2026-07-02 15:29:16 +02:00
commit 83428152a1
26 changed files with 1646 additions and 0 deletions
@@ -0,0 +1,41 @@
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.inject.RegisterRestClient;
@Path("/")
@RegisterRestClient(configKey = "musicbrainz")
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,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,32 @@
package com.discdrop.client.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ReleaseGroupBrowseResponse(
List<ReleaseGroup> releaseGroups,
int releaseGroupCount
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record ReleaseGroup(
String id,
String title,
String firstReleaseDate,
PrimaryType primaryType,
List<SecondaryType> secondaryTypes,
List<ArtistCredit> artistCredit
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record PrimaryType(String id, String name) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record SecondaryType(String id, String name) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ArtistCredit(Artist artist) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Artist(String id, String name) {}
}
}
}
@@ -0,0 +1,346 @@
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 List<Record.ReleaseGroup> getFeed(List<String> artistMbids, List<String> primaryTypes) {
var groups = new ArrayList<Record.ReleaseGroup>();
if (artistMbids.isEmpty()) return groups;
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 first_release_date ASC";
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);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
groups.add(mapReleaseGroup(rs));
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return groups;
}
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,111 @@
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 java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@ApplicationScoped
public class ArtistService {
@Inject
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,57 @@
package com.discdrop.service;
import com.discdrop.db.DatabaseService;
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 List<ReleaseGroup> getFeedEntries() {
var artists = artistService.findAll();
if (artists.isEmpty()) return List.of();
var artistMbids = new ArrayList<String>();
for (var a : artists) {
if (cacheService.needsRefresh(a.mbid())) {
var types = getMonitoredTypes(a);
releaseGroupService.fetchAndCache(a.mbid(), a.name(), types);
}
artistMbids.add(a.mbid());
}
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
var monitoredTypes = new ArrayList<String>();
for (var a : artists) {
monitoredTypes.addAll(getMonitoredTypes(a));
}
return db.getFeed(artistMbids, monitoredTypes.isEmpty() ? allTypes : monitoredTypes);
}
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,91 @@
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 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
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().name() : "Other";
if (!monitoredTypes.contains(primaryType)) continue;
String secondaryTypes = "[]";
if (rg.secondaryTypes() != null && !rg.secondaryTypes().isEmpty()) {
try {
secondaryTypes = json.writeValueAsString(
rg.secondaryTypes().stream().map(s -> s.name()).toList());
} 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,124 @@
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());
var guid = new SyndLinkImpl();
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\" onerror=\"this.style.display='none'\">");
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,125 @@
package com.discdrop.web;
import com.discdrop.db.Record.MonitoringFlags;
import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.db.Record.TrackedArtist;
import com.discdrop.service.ArtistService;
import com.discdrop.service.ArtistService.SearchResult;
import com.discdrop.service.CacheService;
import com.discdrop.service.FeedService;
import com.discdrop.service.ReleaseGroupService;
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 org.jboss.resteasy.reactive.RestForm;
import jakarta.ws.rs.NotFoundException;
import java.util.ArrayList;
import java.util.List;
@Path("/api/artists")
public class ArtistController {
@Inject
ArtistService artistService;
@Inject
ReleaseGroupService releaseGroupService;
@Inject
CacheService cacheService;
@Inject
FeedService feedService;
@Inject
Template fragments$search_results;
@Inject
Template fragments$artist_list;
@Inject
Template fragments$feed_entries;
@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 fragments$search_results.data("artists", results);
}
@POST
@Path("/{mbid}/track")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance trackArtist(@PathParam("mbid") String mbid) {
var artist = artistService.addArtist(mbid);
var types = getMonitoredTypes(artist);
releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), types);
var allArtists = artistService.findAll();
return fragments$artist_list.data("artists", allArtists);
}
@DELETE
@Path("/{mbid}")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance removeArtist(@PathParam("mbid") String mbid) {
artistService.removeArtist(mbid);
var allArtists = artistService.findAll();
return fragments$artist_list.data("artists", allArtists);
}
@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 fragments$artist_list.data("artists", allArtists);
}
@GET
@Path("/list")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance artistList() {
var artists = artistService.findAll();
return fragments$artist_list.data("artists", artists);
}
@GET
@Path("/feed-table")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance feedTable() {
var entries = feedService.getFeedEntries();
return fragments$feed_entries.data("entries", entries);
}
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,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.getFeedEntries();
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,40 @@
package com.discdrop.web;
import com.discdrop.db.DatabaseService;
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;
@Path("/api/settings")
public class SettingsController {
@Inject
DatabaseService db;
@Inject
Template fragments$settings_form;
@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 fragments$settings_form.data("cacheTtl", cacheTtl)
.data("defaultTypes", defaultTypes);
}
@PATCH
@Produces(MediaType.TEXT_HTML)
public Response saveSettings(
@QueryParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl,
@QueryParam("default_types") @DefaultValue("Album") String defaultTypes) {
db.setSetting("cache_ttl_hours", cacheTtl);
db.setSetting("default_types", defaultTypes);
return Response.ok("<div class=\"alert alert-success\">Settings saved</div>")
.build();
}
}
+18
View File
@@ -0,0 +1,18 @@
# Server
quarkus.http.port=8080
# MusicBrainz API
quarkus.rest-client."com.discdrop.client.MusicBrainzClient".url=https://musicbrainz.org/ws/2
quarkus.rest-client."com.discdrop.client.MusicBrainzClient".default-headers=User-Agent: DiscDrop/1.0 (david.alvarez.81@gmail.com)
# 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}
@@ -0,0 +1,9 @@
{#include layouts/base}
{#page-content}
<div class="container mx-auto p-4">
<h2 class="text-2xl font-bold mb-4">🎤 Artists</h2>
<div id="artist-list" hx-get="/api/artists/list" hx-trigger="load">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
{/page-content}
@@ -0,0 +1,80 @@
{#if artists.isEmpty()}
<div class="card bg-base-100 shadow-xl p-8 text-center">
<p class="text-lg 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 and click "+ Add"</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Artist</th>
<th>Since</th>
<th>Release Types</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#for artist in artists}
<tr>
<td>
<div class="font-medium">{artist.name}</div>
{#if artist.disambiguation}
<div class="text-xs text-base-content/60">{artist.disambiguation}</div>
{/if}
</td>
<td class="text-sm text-base-content/60">{artist.createdAt}</td>
<td>
<div class="flex flex-wrap gap-1">
<label class="label cursor-pointer gap-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">Album</span>
</label>
<label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs"
{#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">Single</span>
</label>
<label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs"
{#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">EP</span>
</label>
<label class="label cursor-pointer gap-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">Broadcast</span>
</label>
<label class="label cursor-pointer gap-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">Other</span>
</label>
</div>
</td>
<td>
<button class="btn btn-error btn-xs"
hx-delete="/api/artists/{artist.mbid}"
hx-target="#artist-list"
hx-swap="innerHTML"
onclick="return confirm('Remove {artist.name}?')">
Remove
</button>
</td>
</tr>
{/for}
</tbody>
</table>
</div>
{/if}
@@ -0,0 +1,37 @@
{#if entries.isEmpty()}
<div class="card bg-base-100 shadow-xl p-8 text-center">
<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}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Artist</th>
<th>Release</th>
<th>Type</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{#for entry in entries}
<tr>
<td class="font-medium">{entry.artistName}</td>
<td>
<a href="https://musicbrainz.org/release-group/{entry.mbid}"
target="_blank" rel="noopener noreferrer"
class="link link-hover">
{entry.title}
</a>
</td>
<td>
<span class="badge badge-outline badge-sm">{entry.primaryType}</span>
</td>
<td class="text-sm text-base-content/60">{entry.firstReleaseDate}</td>
</tr>
{/for}
</tbody>
</table>
</div>
{/if}
@@ -0,0 +1,31 @@
<div class="card bg-base-100 shadow-xl mt-1 max-h-64 overflow-y-auto">
{#if artists.isEmpty()}
<div class="p-3 text-sm text-base-content/60">No artists found</div>
{:else}
{#for artist in artists}
<div class="flex items-center justify-between p-3 hover:bg-base-200 border-b border-base-200 last:border-b-0">
<div class="flex-1 min-w-0">
<span class="font-medium">{artist.name}</span>
{#if artist.disambiguation}
<span class="text-sm text-base-content/60">— {artist.disambiguation}</span>
{/if}
<br>
<span class="text-xs text-base-content/40">{artist.area}</span>
</div>
<div class="ml-2 flex-shrink-0">
{#if artist.alreadyTracked}
<span class="badge badge-success badge-sm">✓ Added</span>
{:else}
<button class="btn btn-primary btn-xs"
hx-post="/api/artists/{artist.mbid}/track"
hx-target="#artist-list"
hx-swap="innerHTML"
onclick="this.closest('#search-results').innerHTML=''; document.querySelector('[name=q]').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,71 @@
<!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.12.23/dist/full.min.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<style>
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
}
</style>
</head>
<body class="min-h-screen bg-base-200">
<div class="navbar bg-base-100 shadow-lg px-4">
<div class="flex-1 gap-2">
<a href="/" class="text-xl font-bold">🎵 DiscDrop</a>
<div class="tabs tabs-box ml-4 hidden sm:flex">
<a href="/" class="tab {#if activeTab == 'releases'}tab-active{/if}">Releases</a>
<a href="/artists" class="tab {#if activeTab == 'artists'}tab-active{/if}">Artists</a>
</div>
</div>
<div class="flex-none gap-2 relative">
<div class="form-control">
<input type="text" placeholder="Search artists..."
class="input input-bordered input-sm w-48 md:w-64"
name="q"
hx-get="/api/artists/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
hx-swap="innerHTML"
autocomplete="off">
</div>
<div id="search-results" class="search-dropdown"></div>
<select class="select select-bordered select-sm w-full max-w-[8rem]" data-choose-theme>
<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 class="container mx-auto p-4">
{#insert page-content}{/page-content}
</main>
<dialog id="settingsModal" class="modal">
<div class="modal-box" 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,13 @@
{#include layouts/base}
{#page-content}
<div class="container mx-auto p-4">
<h2 class="text-2xl font-bold mb-4">📡 Releases</h2>
<div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div class="mt-4 flex gap-4">
<a href="/feed/rss.xml" class="btn btn-primary">📥 RSS Feed</a>
<a href="/feed/opml" class="btn btn-ghost">📋 OPML</a>
</div>
</div>
{/page-content}