Compare commits

..

8 Commits

Author SHA1 Message Date
david.alvarez beea9a0dcf Fix feed order (DESC) and cover art in RSS
- Change feed SQL ORDER BY to DESC (newest releases first)
- Remove broken img tag from RSS description (JS onerror not supported in RSS readers)
- Replace with link to Cover Art Archive
2026-07-02 16:44:51 +02:00
david.alvarez 2deb335953 Fix MB API deserialization: primary-type and secondary-types are strings not objects 2026-07-02 16:29:48 +02:00
david.alvarez e7f9b6838e Fix search dropdown, async artist add, OOB swaps
- Fix search results dropdown: absolute position relative to search wrapper
- Make release group fetching async via Vert.x executeBlocking (avoid blocking add)
- Add OOB swap for artist list so +Add works from any page
- Simplify search-results template styling
- Add Response type for combined OOB responses
2026-07-02 16:11:38 +02:00
david.alvarez dcdd04df8c Fix UI: navbar layout, search form, theme switcher, content centering
- Wrap search in HTMX form for reliable parameter serialization
- Replace daisyUI data-choose-theme with custom JS (localStorage persistence)
- Restructure navbar: logo+tabs left, search center, theme+settings right
- Center releases page with max-w-4xl constrained layout
- Center artist page content
- Add loading spinner centering
2026-07-02 16:02:42 +02:00
droideparanoico aef7bd067c Add comprehensive README with usage, architecture, and configuration docs 2026-07-02 15:52:17 +02:00
droideparanoico 8f42fc0f09 Add Dockerfile for JVM deployment 2026-07-02 15:49:49 +02:00
droideparanoico a2a3be0529 Fix build and runtime issues
- Fix Quarkus BOM groupId (io.quarkus:quarkus-bom) and version (3.37.1)
- Fix artifact names: quarkus-resteasy, quarkus-resteasy-qute, quarkus-resteasy-client
- Add UserAgentFilter for MusicBrainz API compliance
- Fix @RestClient qualifier on MB client injection points
- Fix Qute template section names: {#insert}{/insert}, {#else} not {:else}
- Fix template injection with @Location annotation
- Remove unused imports
- Fix RSS feed builder to use Synd API
2026-07-02 15:49:36 +02:00
droideparanoico f0be591faf 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
2026-07-02 15:29:16 +02:00
36 changed files with 200 additions and 1435 deletions
-14
View File
@@ -1,14 +0,0 @@
# Prevent local build artifacts from leaking into Docker
target/
data/
*.db
# IDE
.idea/
.vscode/
# OS
.DS_Store
# Gitea
.gitea/
-29
View File
@@ -1,29 +0,0 @@
# 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"
-1
View File
@@ -11,4 +11,3 @@ data/
*.jar *.jar
*.log *.log
.DS_Store .DS_Store
.gitea/
-23
View File
@@ -1,23 +0,0 @@
# 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
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"]
+7 -13
View File
@@ -49,23 +49,18 @@ java -jar target/quarkus-app/quarkus-run.jar
### Docker ### Docker
Multi-stage build — no host pre-build needed, Maven runs inside the container.
```bash ```bash
# Build (from project root) # Build the JAR
docker build -t discdrop . mvn clean package
# Build the image (from project root)
docker build -f src/main/docker/Dockerfile.jvm -t discdrop .
# Run # Run
docker run -p 8080:8080 \ docker run -p 8080:8080 \
-e DISC_DROP_BASE_URL=http://localhost:8080 \ -e DISC_DROP_BASE_URL=http://localhost:8080 \
-v discdrop-data:/app/data \ -v discdrop-data:/app/data \
discdrop discdrop
### Docker Compose
```bash
docker compose up -d
```
``` ```
--- ---
@@ -77,11 +72,10 @@ All configuration is via environment variables:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `DISC_DROP_DB_PATH` | `data/discdrop.db` | Path to the SQLite database file | | `DISC_DROP_DB_PATH` | `data/discdrop.db` | Path to the SQLite database file |
| `DISC_DROP_CACHE_TTL` | `8` | Cache TTL in hours | | `DISC_DROP_CACHE_TTL` | `8` | Cache TTL in hours (how often release groups are refreshed from MusicBrainz) |
| `DISC_DROP_BASE_URL` | `http://localhost:8080` | Public URL for RSS feed links | | `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`. The MusicBrainz User-Agent is set to `DiscDrop/1.0 (david.alvarez.81@gmail.com)` per [MusicBrainz API etiquette](https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting).
--- ---
-18
View File
@@ -1,18 +0,0 @@
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:
+5 -35
View File
@@ -30,22 +30,22 @@
</dependencyManagement> </dependencyManagement>
<dependencies> <dependencies>
<!-- REST server (reactive) --> <!-- REST server (resteasy-reactive successor) -->
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId> <artifactId>quarkus-resteasy</artifactId>
</dependency> </dependency>
<!-- Qute templating --> <!-- Qute templating -->
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-qute</artifactId> <artifactId>quarkus-resteasy-qute</artifactId>
</dependency> </dependency>
<!-- REST Client --> <!-- REST Client -->
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client</artifactId> <artifactId>quarkus-resteasy-client</artifactId>
</dependency> </dependency>
<!-- CDI --> <!-- CDI -->
@@ -75,7 +75,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId> <artifactId>quarkus-resteasy-client-jackson</artifactId>
</dependency> </dependency>
<!-- Test --> <!-- Test -->
@@ -84,46 +84,16 @@
<artifactId>quarkus-junit5</artifactId> <artifactId>quarkus-junit5</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>io.rest-assured</groupId> <groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId> <artifactId>rest-assured</artifactId>
<version>5.5.1</version> <version>5.5.1</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId> <artifactId>quarkus-maven-plugin</artifactId>
+9
View File
@@ -0,0 +1,9 @@
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN apk add --no-cache sqlite
COPY target/quarkus-app/ /app/
EXPOSE 8080
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", "-jar", "/app/quarkus-run.jar"]
@@ -7,12 +7,10 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@Path("/") @Path("/")
@RegisterRestClient(configKey = "musicbrainz") @RegisterRestClient(configKey = "musicbrainz")
@RegisterProvider(UserAgentFilter.class)
public interface MusicBrainzClient { public interface MusicBrainzClient {
@GET @GET
@@ -1,21 +1,16 @@
package com.discdrop.client; package com.discdrop.client;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.client.ClientRequestContext; import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter; import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException; import java.io.IOException;
@Provider @Provider
@ApplicationScoped
public class UserAgentFilter implements ClientRequestFilter { public class UserAgentFilter implements ClientRequestFilter {
@ConfigProperty(name = "discdrop.user-agent")
String userAgent;
@Override @Override
public void filter(ClientRequestContext requestContext) throws IOException { public void filter(ClientRequestContext requestContext) throws IOException {
requestContext.getHeaders().putSingle("User-Agent", userAgent); requestContext.getHeaders().putSingle("User-Agent",
"DiscDrop/1.0 (david.alvarez.81@gmail.com)");
} }
} }
@@ -3,15 +3,10 @@ package com.discdrop.db;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.sql.Connection; import java.sql.*;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import org.sqlite.SQLiteDataSource; import org.sqlite.SQLiteDataSource;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -89,7 +84,7 @@ public class DatabaseService {
public List<Record.TrackedArtist> findAllArtists() { public List<Record.TrackedArtist> findAllArtists() {
var artists = new ArrayList<Record.TrackedArtist>(); var artists = new ArrayList<Record.TrackedArtist>();
var sql = "SELECT mbid, name, disambiguation, area_name, album_monitored, single_monitored, ep_monitored, broadcast_monitored, other_monitored, created_at, updated_at FROM tracked_artist ORDER BY name"; var sql = "SELECT * FROM tracked_artist ORDER BY name";
try (Connection conn = getConnection(); try (Connection conn = getConnection();
Statement stmt = conn.createStatement(); Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) { ResultSet rs = stmt.executeQuery(sql)) {
@@ -189,29 +184,6 @@ public class DatabaseService {
} }
} }
public void deleteArtistCascading(String mbid) {
var sqlRg = "DELETE FROM release_group_cache WHERE artist_mbid = ?";
var sqlAr = "DELETE FROM tracked_artist WHERE mbid = ?";
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement ps1 = conn.prepareStatement(sqlRg);
PreparedStatement ps2 = conn.prepareStatement(sqlAr)) {
ps1.setString(1, mbid);
ps1.executeUpdate();
ps2.setString(1, mbid);
ps2.executeUpdate();
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
// --- release_group_cache DAO --- // --- release_group_cache DAO ---
public void upsertReleaseGroups(List<Record.ReleaseGroup> groups) { public void upsertReleaseGroups(List<Record.ReleaseGroup> groups) {
@@ -239,42 +211,23 @@ public class DatabaseService {
} }
} }
public record FeedResult(List<Record.ReleaseGroup> entries, boolean hasMore) {} public List<Record.ReleaseGroup> getFeed(List<String> artistMbids, List<String> primaryTypes) {
public FeedResult getFeed(Map<String, List<String>> artistTypes, int limit, int offset) {
var groups = new ArrayList<Record.ReleaseGroup>(); var groups = new ArrayList<Record.ReleaseGroup>();
if (artistTypes.isEmpty()) return new FeedResult(groups, false); if (artistMbids.isEmpty()) return groups;
var conditions = new ArrayList<String>(); var placeholders = String.join(",", artistMbids.stream().map(m -> "?").toList());
var params = new ArrayList<Object>(); var sql = "SELECT * FROM release_group_cache WHERE artist_mbid IN (" + placeholders + ")";
for (var entry : artistTypes.entrySet()) { if (!primaryTypes.isEmpty()) {
var types = entry.getValue(); var typePlaceholders = String.join(",", primaryTypes.stream().map(t -> "?").toList());
if (types.isEmpty()) continue; sql += " AND primary_type IN (" + typePlaceholders + ")";
var placeholders = String.join(",", types.stream().map(t -> "?").toList());
conditions.add("(artist_mbid = ? AND primary_type IN (" + placeholders + "))");
params.add(entry.getKey());
params.addAll(types);
} }
sql += " ORDER BY first_release_date DESC";
if (conditions.isEmpty()) return new FeedResult(groups, false);
var sql = "SELECT * FROM release_group_cache WHERE " + String.join(" OR ", conditions);
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(); try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) { PreparedStatement ps = conn.prepareStatement(sql)) {
int idx = 1; int idx = 1;
for (var p : params) { for (var mbid : artistMbids) ps.setString(idx++, mbid);
if (p instanceof String s) ps.setString(idx++, s); for (var type : primaryTypes) ps.setString(idx++, type);
else ps.setObject(idx++, p);
}
if (useLimit) {
ps.setInt(idx++, limit + 1);
ps.setInt(idx, offset);
}
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { while (rs.next()) {
groups.add(mapReleaseGroup(rs)); groups.add(mapReleaseGroup(rs));
@@ -283,9 +236,7 @@ public class DatabaseService {
} catch (SQLException e) { } catch (SQLException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
boolean hasMore = useLimit && groups.size() > limit; return groups;
if (hasMore) groups.remove(groups.size() - 1);
return new FeedResult(groups, hasMore);
} }
public void deleteReleaseGroupsForArtist(String mbid) { public void deleteReleaseGroupsForArtist(String mbid) {
@@ -329,41 +280,6 @@ public class DatabaseService {
return Optional.empty(); return Optional.empty();
} }
public java.util.Map<String, Boolean> getStaleness(List<String> artistMbids, long ttlHours) {
var result = new java.util.LinkedHashMap<String, Boolean>();
if (artistMbids.isEmpty()) return result;
var placeholders = String.join(",", artistMbids.stream().map(m -> "?").toList());
var sql = "SELECT artist_mbid, COUNT(*) as cnt, MAX(cached_at) as last_cached FROM release_group_cache WHERE artist_mbid IN (" + placeholders + ") GROUP BY artist_mbid";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
int idx = 1;
for (var mbid : artistMbids) ps.setString(idx++, mbid);
try (ResultSet rs = ps.executeQuery()) {
var now = Instant.now();
var cached = new java.util.HashMap<String, Long>();
var lastCached = new java.util.HashMap<String, Instant>();
while (rs.next()) {
var mbid = rs.getString("artist_mbid");
cached.put(mbid, rs.getLong("cnt"));
var ts = rs.getString("last_cached");
if (ts != null) {
lastCached.put(mbid, Instant.parse(ts.replace(" ", "T") + "Z"));
}
}
for (var mbid : artistMbids) {
var cnt = cached.getOrDefault(mbid, 0L);
var lc = lastCached.get(mbid);
boolean stale = cnt == 0 || lc == null || lc.plus(ttlHours, java.time.temporal.ChronoUnit.HOURS).isBefore(now);
result.put(mbid, stale);
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return result;
}
// --- settings DAO --- // --- settings DAO ---
public String getSetting(String key, String defaultValue) { public String getSetting(String key, String defaultValue) {
@@ -84,7 +84,8 @@ public class ArtistService {
} }
public void removeArtist(String mbid) { public void removeArtist(String mbid) {
db.deleteArtistCascading(mbid); db.deleteReleaseGroupsForArtist(mbid);
db.deleteArtist(mbid);
} }
public void updateMonitoring(String mbid, MonitoringFlags flags) { public void updateMonitoring(String mbid, MonitoringFlags flags) {
@@ -5,10 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@ApplicationScoped @ApplicationScoped
public class CacheService { public class CacheService {
@@ -28,15 +25,12 @@ public class CacheService {
public boolean isStale(String artistMbid) { public boolean isStale(String artistMbid) {
Optional<Instant> lastCached = db.getLastCachedAt(artistMbid); Optional<Instant> lastCached = db.getLastCachedAt(artistMbid);
if (lastCached.isEmpty()) return true; if (lastCached.isEmpty()) return true;
return lastCached.get().plus(getTtlHours(), ChronoUnit.HOURS).isBefore(Instant.now());
long ttlHours = getTtlHours();
return lastCached.get().plus(ttlHours, ChronoUnit.HOURS).isBefore(Instant.now());
} }
public boolean needsRefresh(String artistMbid) { public boolean needsRefresh(String artistMbid) {
return db.countReleaseGroupsForArtist(artistMbid) == 0 || isStale(artistMbid); return db.countReleaseGroupsForArtist(artistMbid) == 0 || isStale(artistMbid);
} }
public Map<String, Boolean> needsRefresh(List<String> artistMbids) {
var staleness = db.getStaleness(artistMbids, getTtlHours());
return staleness;
}
} }
@@ -1,16 +1,12 @@
package com.discdrop.service; package com.discdrop.service;
import com.discdrop.db.DatabaseService; import com.discdrop.db.DatabaseService;
import com.discdrop.db.DatabaseService.FeedResult;
import com.discdrop.db.Record.ReleaseGroup; import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.db.Record.TrackedArtist; import com.discdrop.db.Record.TrackedArtist;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Map; import java.util.List;
@ApplicationScoped @ApplicationScoped
public class FeedService { public class FeedService {
@@ -27,41 +23,26 @@ public class FeedService {
@Inject @Inject
DatabaseService db; DatabaseService db;
public static final int PAGE_SIZE = 10; public List<ReleaseGroup> getFeedEntries() {
public List<ReleaseGroup> getAllFeedEntries() {
var artists = artistService.findAll(); var artists = artistService.findAll();
if (artists.isEmpty()) return List.of(); if (artists.isEmpty()) return List.of();
refreshStaleCaches(artists);
return db.getFeed(buildArtistTypesMap(artists), 0, 0).entries();
}
public FeedResult getFeedEntries(int page) { var artistMbids = new ArrayList<String>();
var artists = artistService.findAll();
if (artists.isEmpty()) return new FeedResult(List.of(), false);
refreshStaleCaches(artists);
var offset = (page - 1) * PAGE_SIZE;
return db.getFeed(buildArtistTypesMap(artists), PAGE_SIZE, offset);
}
private Map<String, List<String>> buildArtistTypesMap(List<TrackedArtist> artists) {
var map = new LinkedHashMap<String, List<String>>();
for (var a : artists) { for (var a : artists) {
var types = getMonitoredTypes(a); if (cacheService.needsRefresh(a.mbid())) {
if (!types.isEmpty()) map.put(a.mbid(), types); var types = getMonitoredTypes(a);
} releaseGroupService.fetchAndCache(a.mbid(), a.name(), types);
return map;
}
private void refreshStaleCaches(List<TrackedArtist> artists) {
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
var mbids = artists.stream().map(TrackedArtist::mbid).toList();
var staleness = cacheService.needsRefresh(mbids);
for (var a : artists) {
if (staleness.getOrDefault(a.mbid(), false)) {
releaseGroupService.fetchAndCache(a.mbid(), a.name(), allTypes);
} }
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) { private List<String> getMonitoredTypes(TrackedArtist a) {
@@ -45,6 +45,8 @@ public class ReleaseGroupService {
for (var rg : page.releaseGroups()) { for (var rg : page.releaseGroups()) {
String primaryType = rg.primaryType() != null ? rg.primaryType() : "Other"; String primaryType = rg.primaryType() != null ? rg.primaryType() : "Other";
if (!monitoredTypes.contains(primaryType)) continue;
String secondaryTypes = "[]"; String secondaryTypes = "[]";
if (rg.secondaryTypes() != null && !rg.secondaryTypes().isEmpty()) { if (rg.secondaryTypes() != null && !rg.secondaryTypes().isEmpty()) {
try { try {
@@ -1,12 +1,7 @@
package com.discdrop.util; package com.discdrop.util;
import com.discdrop.db.Record.ReleaseGroup; import com.discdrop.db.Record.ReleaseGroup;
import com.rometools.rome.feed.synd.SyndCategoryImpl; import com.rometools.rome.feed.synd.*;
import com.rometools.rome.feed.synd.SyndContentImpl;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndEntryImpl;
import com.rometools.rome.feed.synd.SyndFeedImpl;
import com.rometools.rome.feed.synd.SyndLinkImpl;
import com.rometools.rome.io.SyndFeedOutput; import com.rometools.rome.io.SyndFeedOutput;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
@@ -84,8 +79,6 @@ public class RSSFeedBuilder {
private String buildDescription(ReleaseGroup rg) { private String buildDescription(ReleaseGroup rg) {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.append("<img src=\"https://coverartarchive.org/release-group/")
.append(rg.mbid()).append("/front-250\" alt=\"Cover\">");
sb.append("<p><b>").append(escHtml(rg.artistName())).append("</b>") sb.append("<p><b>").append(escHtml(rg.artistName())).append("</b>")
.append(" <i>").append(escHtml(rg.title())).append("</i><br>"); .append(" <i>").append(escHtml(rg.title())).append("</i><br>");
sb.append("Type: ").append(escHtml(rg.primaryType())); sb.append("Type: ").append(escHtml(rg.primaryType()));
@@ -93,6 +86,8 @@ public class RSSFeedBuilder {
sb.append(" | Secondary: ").append(escHtml(rg.secondaryTypes())); sb.append(" | Secondary: ").append(escHtml(rg.secondaryTypes()));
} }
sb.append("<br>First Release Date: ").append(rg.firstReleaseDate()); sb.append("<br>First Release Date: ").append(rg.firstReleaseDate());
sb.append("<br><a href=\"https://coverartarchive.org/release-group/")
.append(rg.mbid()).append("/front\">Cover Art</a>");
sb.append("</p>"); sb.append("</p>");
return sb.toString(); return sb.toString();
} }
@@ -10,18 +10,7 @@ import io.quarkus.qute.Location;
import io.quarkus.qute.Template; import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance; import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.*;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.ArrayList; import java.util.ArrayList;
@@ -69,10 +58,12 @@ public class ArtistController {
@Produces(MediaType.TEXT_HTML) @Produces(MediaType.TEXT_HTML)
public Response trackArtist(@PathParam("mbid") String mbid) { public Response trackArtist(@PathParam("mbid") String mbid) {
var artist = artistService.addArtist(mbid); var artist = artistService.addArtist(mbid);
releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), List.of("Album", "Single", "EP", "Broadcast", "Other")); var types = getMonitoredTypes(artist);
releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), types);
var allArtists = artistService.findAll(); var allArtists = artistService.findAll();
var listHtml = artistListTemplate.data("artists", allArtists).render(); var listHtml = artistListTemplate.data("artists", allArtists).render();
return Response.ok(listHtml + feedReloadScript()).build(); var emptySearch = searchResultsTemplate.data("artists", List.of()).render();
return Response.ok(emptySearch + listHtml).build();
} }
@DELETE @DELETE
@@ -84,27 +75,6 @@ public class ArtistController {
return artistListTemplate.data("artists", allArtists); 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 @PATCH
@Path("/{mbid}/monitor") @Path("/{mbid}/monitor")
@Produces(MediaType.TEXT_HTML) @Produces(MediaType.TEXT_HTML)
@@ -113,15 +83,12 @@ public class ArtistController {
@QueryParam("type") String type) { @QueryParam("type") String type) {
var existing = artistService.findByMbid(mbid) var existing = artistService.findByMbid(mbid)
.orElseThrow(() -> new NotFoundException("Artist not found")); .orElseThrow(() -> new NotFoundException("Artist not found"));
if (type == null || type.isBlank()) {
throw new jakarta.ws.rs.BadRequestException("type parameter is required");
}
var flags = new MonitoringFlags( var flags = new MonitoringFlags(
"album".equals(type) ? !existing.albumMonitored() : existing.albumMonitored(), type.equals("album") ? !existing.albumMonitored() : existing.albumMonitored(),
"single".equals(type) ? !existing.singleMonitored() : existing.singleMonitored(), type.equals("single") ? !existing.singleMonitored() : existing.singleMonitored(),
"ep".equals(type) ? !existing.epMonitored() : existing.epMonitored(), type.equals("ep") ? !existing.epMonitored() : existing.epMonitored(),
"broadcast".equals(type) ? !existing.broadcastMonitored() : existing.broadcastMonitored(), type.equals("broadcast") ? !existing.broadcastMonitored() : existing.broadcastMonitored(),
"other".equals(type) ? !existing.otherMonitored() : existing.otherMonitored() type.equals("other") ? !existing.otherMonitored() : existing.otherMonitored()
); );
artistService.updateMonitoring(mbid, flags); artistService.updateMonitoring(mbid, flags);
var allArtists = artistService.findAll(); var allArtists = artistService.findAll();
@@ -139,12 +106,9 @@ public class ArtistController {
@GET @GET
@Path("/feed-table") @Path("/feed-table")
@Produces(MediaType.TEXT_HTML) @Produces(MediaType.TEXT_HTML)
public TemplateInstance feedTable(@QueryParam("page") @DefaultValue("1") int page) { public TemplateInstance feedTable() {
var result = feedService.getFeedEntries(page); var entries = feedService.getFeedEntries();
return feedEntriesTemplate return feedEntriesTemplate.data("entries", entries);
.data("entries", result.entries())
.data("hasMore", result.hasMore())
.data("nextPage", page + 1);
} }
private List<String> getMonitoredTypes(TrackedArtist a) { private List<String> getMonitoredTypes(TrackedArtist a) {
@@ -156,8 +120,4 @@ public class ArtistController {
if (a.otherMonitored()) types.add("Other"); if (a.otherMonitored()) types.add("Other");
return types; 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>";
}
} }
@@ -22,7 +22,7 @@ public class FeedController {
@Path("/rss.xml") @Path("/rss.xml")
@Produces(MediaType.APPLICATION_XML) @Produces(MediaType.APPLICATION_XML)
public Response rssFeed() { public Response rssFeed() {
var entries = feedService.getAllFeedEntries(); var entries = feedService.getFeedEntries();
var xml = feedBuilder.buildRss(entries); var xml = feedBuilder.buildRss(entries);
return Response.ok(xml, MediaType.APPLICATION_XML).build(); return Response.ok(xml, MediaType.APPLICATION_XML).build();
} }
@@ -5,16 +5,9 @@ import io.quarkus.qute.Location;
import io.quarkus.qute.Template; import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance; import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.*;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.List;
@Path("/api/settings") @Path("/api/settings")
public class SettingsController { public class SettingsController {
@@ -37,25 +30,12 @@ public class SettingsController {
} }
@PATCH @PATCH
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_HTML) @Produces(MediaType.TEXT_HTML)
public Response saveSettings( public Response saveSettings(
@FormParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl, @QueryParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl,
@FormParam("default_types") List<String> defaultTypes) { @QueryParam("default_types") @DefaultValue("Album") String defaultTypes) {
try {
var ttl = Integer.parseInt(cacheTtl);
if (ttl < 1 || ttl > 168) {
return Response.status(400)
.entity("<div class=\"alert alert-error\">Cache TTL must be between 1 and 168 hours</div>")
.build();
}
} catch (NumberFormatException e) {
return Response.status(400)
.entity("<div class=\"alert alert-error\">Invalid cache TTL value</div>")
.build();
}
db.setSetting("cache_ttl_hours", cacheTtl); db.setSetting("cache_ttl_hours", cacheTtl);
db.setSetting("default_types", defaultTypes != null ? String.join(",", defaultTypes) : "Album"); db.setSetting("default_types", defaultTypes);
return Response.ok("<div class=\"alert alert-success\">Settings saved</div>") return Response.ok("<div class=\"alert alert-success\">Settings saved</div>")
.build(); .build();
} }
@@ -15,6 +15,3 @@ quarkus.jackson.fail-on-unknown-properties=false
discdrop.db.path=${DISC_DROP_DB_PATH:data/discdrop.db} discdrop.db.path=${DISC_DROP_DB_PATH:data/discdrop.db}
discdrop.cache.ttl-hours=${DISC_DROP_CACHE_TTL:8} discdrop.cache.ttl-hours=${DISC_DROP_CACHE_TTL:8}
discdrop.base-url=${DISC_DROP_BASE_URL:http://localhost:8080} 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/1.0 (https://github.com/droideparanoico/discdrop)}
+3 -3
View File
@@ -1,9 +1,9 @@
{#include layouts/base} {#include layouts/base}
{#page-content} {#page-content}
<div class="w-full"> <div class="w-full max-w-4xl mx-auto">
<h2 class="text-2xl font-bold mb-5">🎤 Tracked Artists</h2> <h2 class="text-2xl font-bold mb-4">🎤 Artists</h2>
<div id="artist-list" hx-get="/api/artists/list" hx-trigger="load"> <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> <span class="loading loading-spinner loading-lg block mx-auto"></span>
</div> </div>
</div> </div>
{/page-content} {/page-content}
@@ -1,84 +1,81 @@
<div id="artist-list" hx-swap-oob="true"> <div id="artist-list" hx-swap-oob="true">
{#if artists.isEmpty()} {#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"> <div class="card bg-base-100 shadow-xl p-8 text-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"> <p class="text-lg text-base-content/60">No artists tracked yet</p>
<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"/> <p class="text-sm text-base-content/40 mt-2">Search for artists using the search bar above and click "+ Add"</p>
</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> </div>
{#else} {#else}
<div class="overflow-x-auto rounded-box border border-base-300"> <div class="overflow-x-auto">
<table class="table table-pin-rows"> <table class="table table-zebra">
<thead> <thead>
<tr style="background:var(--fallback-b2,oklch(var(--b2)/1))"> <tr>
<th>Artist</th> <th>Artist</th>
<th>Since</th> <th>Since</th>
<th>Release Types</th> <th>Release Types</th>
<th style="width:6rem">Remove</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#for artist in artists} {#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=''"> <tr>
<td> <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> <div class="font-medium">{artist.name}</div>
{#if artist.disambiguation} {#if artist.disambiguation}
<div class="text-xs text-base-content/50">{artist.disambiguation}</div> <div class="text-xs text-base-content/60">{artist.disambiguation}</div>
{/if} {/if}
</td> </td>
<td class="text-sm text-base-content/50">{artist.createdAt}</td> <td class="text-sm text-base-content/60">{artist.createdAt}</td>
<td> <td>
<div style="display:flex;flex-wrap:wrap;gap:0.375rem"> <div class="flex flex-wrap gap-1">
<label class="label cursor-pointer gap-1 p-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" <input type="checkbox" class="checkbox checkbox-xs checkbox-primary"
{#if artist.albumMonitored}checked{/if} {#if artist.albumMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=album" hx-patch="/api/artists/{artist.mbid}/monitor?type=album"
hx-target="#artist-list" hx-target="#artist-list"
hx-swap="innerHTML"> hx-swap="innerHTML">
<span class="label-text text-xs" style="color:var(--p)">Album</span> <span class="label-text text-xs">Album</span>
</label> </label>
<label class="label cursor-pointer gap-1 p-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs checkbox-secondary" <input type="checkbox" class="checkbox checkbox-xs"
{#if artist.singleMonitored}checked{/if} {#if artist.singleMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=single" hx-patch="/api/artists/{artist.mbid}/monitor?type=single"
hx-target="#artist-list" hx-target="#artist-list"
hx-swap="innerHTML"> hx-swap="innerHTML">
<span class="label-text text-xs" style="color:var(--s)">Single</span> <span class="label-text text-xs">Single</span>
</label> </label>
<label class="label cursor-pointer gap-1 p-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs checkbox-accent" <input type="checkbox" class="checkbox checkbox-xs"
{#if artist.epMonitored}checked{/if} {#if artist.epMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=ep" hx-patch="/api/artists/{artist.mbid}/monitor?type=ep"
hx-target="#artist-list" hx-target="#artist-list"
hx-swap="innerHTML"> hx-swap="innerHTML">
<span class="label-text text-xs" style="color:var(--a)">EP</span> <span class="label-text text-xs">EP</span>
</label> </label>
<label class="label cursor-pointer gap-1 p-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs" <input type="checkbox" class="checkbox checkbox-xs"
{#if artist.broadcastMonitored}checked{/if} {#if artist.broadcastMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=broadcast" hx-patch="/api/artists/{artist.mbid}/monitor?type=broadcast"
hx-target="#artist-list" hx-target="#artist-list"
hx-swap="innerHTML"> hx-swap="innerHTML">
<span class="label-text text-xs text-base-content/60">Broadcast</span> <span class="label-text text-xs">Broadcast</span>
</label> </label>
<label class="label cursor-pointer gap-1 p-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs" <input type="checkbox" class="checkbox checkbox-xs"
{#if artist.otherMonitored}checked{/if} {#if artist.otherMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=other" hx-patch="/api/artists/{artist.mbid}/monitor?type=other"
hx-target="#artist-list" hx-target="#artist-list"
hx-swap="innerHTML"> hx-swap="innerHTML">
<span class="label-text text-xs text-base-content/60">Other</span> <span class="label-text text-xs">Other</span>
</label> </label>
</div> </div>
</td> </td>
<td> <td>
<button class="btn btn-ghost btn-sm btn-square" style="color:var(--er)" <button class="btn btn-error btn-xs"
hx-delete="/api/artists/{artist.mbid}" hx-delete="/api/artists/{artist.mbid}"
hx-target="#artist-list" hx-target="#artist-list"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="Remove {artist.name}?"> 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> Remove
</button> </button>
</td> </td>
</tr> </tr>
@@ -1,46 +1,37 @@
{#if entries.isEmpty()} {#if entries.isEmpty()}
<div class="card bg-base-200 shadow-xl text-center" style="padding:3rem"> <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-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> <p class="text-sm text-base-content/40 mt-2">Add artists to your collection to see their releases here</p>
</div> </div>
{#else} {#else}
{#for entry in entries} <div class="overflow-x-auto">
<a href="https://musicbrainz.org/release-group/{entry.mbid}" target="_blank" rel="noopener noreferrer" <table class="table table-zebra">
class="card card-side bg-base-200 border border-base-300 hover:bg-base-300 card-shadow overflow-hidden"> <thead>
<figure class="w-64"> <tr>
<img src="https://coverartarchive.org/release-group/{entry.mbid}/front-250" <th>Artist</th>
alt="{entry.title}" class="object-cover w-full h-full" <th>Release</th>
loading="lazy" <th>Type</th>
onerror="this.parentElement.innerHTML='<div class=\'flex items-center justify-center h-full text-base-content/20\'>No Cover</div>'"> <th>Date</th>
</figure> </tr>
<div class="card-body"> </thead>
<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> <tbody>
<h2 style="font-size:2rem;font-weight:700">{entry.title}</h2> {#for entry in entries}
<div style="margin-top:0.75rem"> <tr>
{#if entry.firstReleaseDate} <td class="font-medium">{entry.artistName}</td>
<div style="font-size:1.5rem;color:var(--fallback-bc,oklch(var(--bc)/0.4))">{entry.firstReleaseDate}</div> <td>
{/if} <a href="https://musicbrainz.org/release-group/{entry.mbid}"
<div style="margin-top:0.4rem"> target="_blank" rel="noopener noreferrer"
{#if entry.primaryType == 'Album'} class="link link-hover">
<span class="badge badge-primary" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">Album</span> {entry.title}
{#else if entry.primaryType == 'Single'} </a>
<span class="badge badge-secondary" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">Single</span> </td>
{#else if entry.primaryType == 'EP'} <td>
<span class="badge badge-accent" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">EP</span> <span class="badge badge-outline badge-sm">{entry.primaryType}</span>
{#else} </td>
<span class="badge badge-ghost" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">{entry.primaryType}</span> <td class="text-sm text-base-content/60">{entry.firstReleaseDate}</td>
{/if} </tr>
</div> {/for}
</div> </tbody>
</div> </table>
</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> </div>
{/if} {/if}
{/if}
@@ -1,37 +1,28 @@
<div class="card bg-base-300 dropdown-shadow border border-base-300 mt-1" style="max-height:18rem;overflow-y:auto"> <div class="bg-base-100 shadow-xl border border-base-300 rounded-box mt-1 max-h-64 overflow-y-auto">
{#if artists.isEmpty()} {#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> <div class="p-3 text-sm text-base-content/60">No artists found</div>
{#else} {#else}
{#for artist in artists} {#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" <div class="flex items-center justify-between p-3 hover:bg-base-200 border-b border-base-200 last:border-b-0">
onmouseover="this.style.background='var(--fallback-b2,oklch(var(--b2)/1))'" onmouseout="this.style.background=''"> <div class="flex-1 min-w-0">
<div style="flex:1;min-width:0;margin-right:0.5rem"> <span class="font-medium">{artist.name}</span>
<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} {#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> <span class="text-sm text-base-content/60">{artist.disambiguation}</span>
{/if}
{#if artist.area}
<div style="font-size:0.75rem;color:var(--fallback-bc,oklch(var(--bc)/0.4))">{artist.area}</div>
{/if} {/if}
<br>
<span class="text-xs text-base-content/40">{artist.area}</span>
</div> </div>
<div class="flex-shrink-0"> <div class="ml-2 flex-shrink-0">
{#if artist.alreadyTracked} {#if artist.alreadyTracked}
<button class="btn btn-warning btn-sm" <span class="badge badge-success badge-sm">✓ Added</span>
hx-delete="/api/artists/{artist.mbid}/from-search"
hx-include="#search-input"
hx-target="#search-results"
hx-swap="innerHTML">✓ Added</button>
{#else} {#else}
<button class="btn btn-primary btn-sm" <button class="btn btn-primary btn-xs"
hx-post="/api/artists/{artist.mbid}/track" hx-post="/api/artists/{artist.mbid}/track"
hx-target="#search-results" hx-target="#search-results"
hx-swap="innerHTML" hx-swap="innerHTML"
onclick="document.getElementById('search-input').value=''">+ Add</button> onclick="document.getElementById('search-input').value=''">
+ Add
</button>
{/if} {/if}
</div> </div>
</div> </div>
+40 -35
View File
@@ -4,34 +4,25 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DiscDrop</title> <title>DiscDrop</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet"> <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/dist/htmx.min.js"></script> <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<style> <style>
body { #search-wrapper {
width: 100vw; position: relative;
overflow-x: hidden; }
#search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
width: 100%;
} }
#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> </style>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var saved = localStorage.getItem('daisyui-theme'); var saved = localStorage.getItem('daisyui-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved); 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) { function setTheme(sel) {
var theme = sel.value; var theme = sel.value;
@@ -40,28 +31,39 @@
} }
</script> </script>
</head> </head>
<body class="min-h-screen bg-base-100"> <body class="min-h-screen bg-base-200">
<div class="navbar bg-base-300 shadow-lg px-4 min-h-0 h-14"> <div class="navbar bg-base-100 shadow-lg px-4">
<div class="navbar-start"></div> <div class="navbar-start">
<div class="flex items-center gap-2">
<a href="/" class="text-xl font-bold">🎵 DiscDrop</a>
<div class="tabs tabs-box ml-2 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>
<div class="navbar-center gap-4"> <div class="navbar-center">
<a href="/" class="btn btn-ghost btn-sm no-underline">DiscDrop</a> <div id="search-wrapper" class="w-48 md:w-64">
<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" <form hx-get="/api/artists/search"
hx-target="#search-results" hx-target="#search-results"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-trigger="keyup changed delay:300ms from:#search-input"> hx-trigger="keyup changed delay:300ms from:#search-input">
<input id="search-input" type="text" placeholder="Search artist to follow" <input id="search-input"
class="input input-bordered input-sm" name="q" autocomplete="off" style="width:100%"> type="text"
placeholder="Search artists..."
class="input input-bordered input-sm w-full"
name="q"
autocomplete="off">
</form> </form>
<div id="search-results"></div> <div id="search-results"></div>
</div> </div>
</div> </div>
<div class="navbar-end gap-2"> <div class="navbar-end gap-2">
<select class="select select-bordered select-sm" style="width:7rem" onchange="setTheme(this)"> <select class="select select-bordered select-sm w-full max-w-[8rem]"
<option value="dark">Dark</option> onchange="setTheme(this)">
<option value="dark" selected>Dark</option>
<option value="light">Light</option> <option value="light">Light</option>
<option value="synthwave">Synthwave</option> <option value="synthwave">Synthwave</option>
<option value="retro">Retro</option> <option value="retro">Retro</option>
@@ -71,17 +73,20 @@
<option value="nord">Nord</option> <option value="nord">Nord</option>
</select> </select>
<button class="btn btn-ghost btn-sm" onclick="settingsModal.showModal()">⚙️</button> <button class="btn btn-ghost btn-sm"
onclick="settingsModal.showModal()">⚙️</button>
</div> </div>
</div> </div>
<main> <main class="container mx-auto p-4">
{#insert page-content}{/insert} {#insert page-content}{/insert}
</main> </main>
<dialog id="settingsModal" class="modal"> <dialog id="settingsModal" class="modal">
<div class="modal-box max-w-md" id="settings-modal-content" <div class="modal-box max-w-md" id="settings-modal-content"
hx-get="/api/settings/form" hx-trigger="load" hx-swap="innerHTML"> hx-get="/api/settings/form"
hx-trigger="load"
hx-swap="innerHTML">
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
</div> </div>
</dialog> </dialog>
+7 -20
View File
@@ -1,26 +1,13 @@
{#include layouts/base} {#include layouts/base}
{#page-content} {#page-content}
<div class="w-full"> <div class="w-full max-w-4xl mx-auto">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;flex-wrap:wrap;gap:0.75rem"> <h2 class="text-2xl font-bold mb-4 text-center">📡 Releases</h2>
<h2 style="font-size:1.5rem;font-weight:700">📡 Releases</h2> <div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load">
<div style="display:flex;gap:0.5rem"> <span class="loading loading-spinner loading-lg block mx-auto"></span>
<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>
<div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load" style="display:flex;flex-direction:column;gap:2.5rem"> <div class="mt-6 flex justify-center gap-4">
<div style="display:flex;justify-content:center;padding:4rem 0"> <a href="/feed/rss.xml" class="btn btn-primary">📥 RSS Feed</a>
<span class="loading loading-spinner loading-lg"></span> <a href="/feed/opml" class="btn btn-ghost">📋 OPML</a>
</div>
</div> </div>
</div> </div>
{/page-content} {/page-content}
@@ -1,79 +0,0 @@
package com.discdrop;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.db.Record.TrackedArtist;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class AdditionalServiceTest {
@Inject
DatabaseService db;
@BeforeEach
void cleanUp() {
for (var a : db.findAllArtists()) {
db.deleteArtistCascading(a.mbid());
}
}
@Test
void testGetFeedWithOnlyAlbumFilter() {
db.insertArtist(new TrackedArtist("only-album", "Only Album", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("rg-oa-1", "Album", "Album", "[]", "2020-01-01", "only-album", "Only Album", Instant.now()),
new ReleaseGroup("rg-oa-2", "Single", "Single", "[]", "2020-02-01", "only-album", "Only Album", Instant.now())
));
var result = db.getFeed(Map.of("only-album", List.of("Album")), 10, 0);
assertEquals(1, result.entries().size());
}
@Test
void testGetFeedWithNoMatchingTypes() {
db.insertArtist(new TrackedArtist("no-match", "No Match", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("rg-nm-1", "Album", "Album", "[]", "2020-01-01", "no-match", "No Match", Instant.now())
));
var result = db.getFeed(Map.of("no-match", List.of("Single")), 10, 0);
assertTrue(result.entries().isEmpty());
}
@Test
void testArtistListSortOrder() {
db.insertArtist(new TrackedArtist("z-artist", "ZZZ", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.insertArtist(new TrackedArtist("a-artist", "AAA", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
var artists = db.findAllArtists();
assertEquals("AAA", artists.get(0).name());
assertEquals("ZZZ", artists.get(1).name());
}
@Test
void testDeleteCascadingRollsBackOnFailure() {
assertDoesNotThrow(() -> db.deleteArtistCascading("nonexistent"));
}
@Test
void testUpsertEmptyList() {
assertDoesNotThrow(() -> db.upsertReleaseGroups(List.of()));
}
@Test
void testGetStalenessEmpty() {
assertTrue(db.getStaleness(List.of(), 8).isEmpty());
}
}
@@ -1,151 +0,0 @@
package com.discdrop;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.TrackedArtist;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class ArtistControllerTest {
@Inject
DatabaseService db;
@BeforeEach
void cleanUp() {
for (var a : db.findAllArtists()) {
db.deleteArtistCascading(a.mbid());
}
}
@Test
void testSearchEndpointEmpty() {
given().queryParam("q", "").get("/api/artists/search")
.then().statusCode(200).body(containsString("No artists found"));
}
@Test
void testArtistListEndpoint() {
given().get("/api/artists/list")
.then().statusCode(200).body(containsString("artist-list"));
}
@Test
void testSettingsForm() {
given().get("/api/settings/form")
.then().statusCode(200).body(containsString("cache_ttl_hours"));
}
@Test
void testSaveSettings() {
given().formParam("cache_ttl_hours", "12").formParam("default_types", "Album", "Single")
.patch("/api/settings").then().statusCode(200).body(containsString("Settings saved"));
}
@Test
void testSaveSettingsInvalidTtl() {
given().formParam("cache_ttl_hours", "invalid").formParam("default_types", "Album")
.patch("/api/settings").then().statusCode(400);
}
@Test
void testFeedTableEndpoint() {
given().get("/api/artists/feed-table").then().statusCode(200);
}
@Test
void testFeedTableWithPage() {
given().queryParam("page", "1").get("/api/artists/feed-table").then().statusCode(200);
}
@Test
void testReleasesPage() {
given().get("/").then().statusCode(200).body(containsString("DiscDrop"));
}
@Test
void testArtistsPage() {
given().get("/artists").then().statusCode(200);
}
@Test
void testRssFeed() {
given().get("/feed/rss.xml").then().statusCode(200).contentType(ContentType.XML);
}
@Test
void testOpml() {
given().get("/feed/opml").then().statusCode(200).contentType(ContentType.XML);
}
@Test
void testDeleteArtist() {
db.insertArtist(new TrackedArtist("del-1", "Delete", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
given().delete("/api/artists/del-1").then().statusCode(200);
assertFalse(db.exists("del-1"));
}
@Test
void testDeleteArtistFromSearch() {
db.insertArtist(new TrackedArtist("del-src", "SearchDel", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
given().queryParam("q", "").delete("/api/artists/del-src/from-search").then().statusCode(200);
}
@Test
void testMonitoringToggleSingle() {
db.insertArtist(new TrackedArtist("mon-ts", "ToggleS", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
given().queryParam("type", "single").patch("/api/artists/mon-ts/monitor").then().statusCode(200);
assertTrue(db.findByMbid("mon-ts").orElseThrow().singleMonitored());
}
@Test
void testMonitoringToggleAlbum() {
db.insertArtist(new TrackedArtist("mon-ta", "ToggleA", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
given().queryParam("type", "album").patch("/api/artists/mon-ta/monitor").then().statusCode(200);
assertFalse(db.findByMbid("mon-ta").orElseThrow().albumMonitored());
}
@Test
void testMonitoringNonexistent() {
given().queryParam("type", "album").patch("/api/artists/no-such/monitor").then().statusCode(404);
}
@Test
void testMonitoringMissingType() {
db.insertArtist(new TrackedArtist("mon-mt", "MissType", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
given().patch("/api/artists/mon-mt/monitor").then().statusCode(400);
}
@Test
void testTrackArtist() {
given().post("/api/artists/mock-mbid-1/track").then().statusCode(200);
assertTrue(db.exists("mock-mbid-1"));
}
@Test
void testTrackArtistAndVerifyFeed() {
given().post("/api/artists/mock-mbid-1/track").then().statusCode(200);
given().get("/api/artists/feed-table").then().statusCode(200);
}
@Test
void testDeleteTrackedArtistFromSearch() {
given().post("/api/artists/mock-mbid-1/track").then().statusCode(200);
given().queryParam("q", "Mock").delete("/api/artists/mock-mbid-1/from-search").then().statusCode(200);
assertFalse(db.exists("mock-mbid-1"));
}
}
@@ -1,90 +0,0 @@
package com.discdrop;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.MonitoringFlags;
import com.discdrop.db.Record.TrackedArtist;
import com.discdrop.service.ArtistService;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class ArtistServiceTest {
@Inject
ArtistService artistService;
@Inject
DatabaseService db;
@BeforeEach
void cleanUp() {
for (var a : db.findAllArtists()) {
db.deleteArtistCascading(a.mbid());
}
}
@Test
void testSearchUsesMock() {
var results = artistService.search("Mock");
assertEquals(1, results.size());
assertEquals("Mock Band", results.get(0).name());
assertFalse(results.get(0).alreadyTracked());
}
@Test
void testSearchEmpty() {
var results = artistService.search("");
assertTrue(results.isEmpty());
}
@Test
void testAddArtist() {
var artist = artistService.addArtist("mock-mbid-1");
assertEquals("Mock Band", artist.name());
assertTrue(db.exists("mock-mbid-1"));
assertTrue(artist.albumMonitored());
}
@Test
void testAddArtistThenSearchShowsTracked() {
artistService.addArtist("mock-mbid-1");
var results = artistService.search("Mock");
assertTrue(results.get(0).alreadyTracked());
}
@Test
void testRemoveArtist() {
artistService.addArtist("mock-mbid-2");
assertTrue(db.exists("mock-mbid-2"));
artistService.removeArtist("mock-mbid-2");
assertFalse(db.exists("mock-mbid-2"));
}
@Test
void testUpdateMonitoring() {
artistService.addArtist("mock-mbid-3");
artistService.updateMonitoring("mock-mbid-3", new MonitoringFlags(false, true, false, false, false));
var artist = db.findByMbid("mock-mbid-3").orElseThrow();
assertFalse(artist.albumMonitored());
assertTrue(artist.singleMonitored());
}
@Test
void testFindByMbidNotFound() {
assertTrue(artistService.findByMbid("nonexistent").isEmpty());
}
@Test
void testFindAll() {
artistService.addArtist("mock-mbid-4");
artistService.addArtist("mock-mbid-5");
assertEquals(2, artistService.findAll().size());
}
}
@@ -1,93 +0,0 @@
package com.discdrop;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.db.Record.TrackedArtist;
import com.discdrop.service.CacheService;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class CacheServiceTest {
@Inject
CacheService cacheService;
@Inject
DatabaseService db;
@BeforeEach
void cleanUp() {
for (var a : db.findAllArtists()) {
db.deleteArtistCascading(a.mbid());
}
db.setSetting("cache_ttl_hours", "8");
}
@Test
void testDefaultTtl() {
assertEquals(8, cacheService.getTtlHours());
}
@Test
void testCustomTtl() {
db.setSetting("cache_ttl_hours", "24");
assertEquals(24, cacheService.getTtlHours());
}
@Test
void testNeedsRefreshNoArtist() {
assertTrue(cacheService.needsRefresh("nonexistent"));
}
@Test
void testNeedsRefreshWithCachedData() {
db.insertArtist(new TrackedArtist("cached-artist", "Cached", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("rg-cache-1", "Cached Release", "Album", "[]",
"2020-01-01", "cached-artist", "Cached", Instant.now())
));
assertFalse(cacheService.needsRefresh("cached-artist"));
}
@Test
void testBatchStaleness() {
db.insertArtist(new TrackedArtist("batch-a", "A", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.insertArtist(new TrackedArtist("batch-b", "B", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("rg-ba-1", "R1", "Album", "[]", "2020-01-01", "batch-b", "B", Instant.now())
));
var staleness = cacheService.needsRefresh(List.of("batch-a", "batch-b"));
assertTrue(staleness.get("batch-a"));
assertFalse(staleness.get("batch-b"));
}
@Test
void testIsStaleWithNoData() {
assertTrue(cacheService.isStale("nonexistent-artist"));
}
@Test
void testIsStaleWithFreshData() {
db.insertArtist(new TrackedArtist("fresh-artist", "Fresh", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("rg-fresh-1", "Fresh Release", "Album", "[]",
"2020-01-01", "fresh-artist", "Fresh", Instant.now())
));
assertFalse(cacheService.isStale("fresh-artist"));
}
}
@@ -1,161 +0,0 @@
package com.discdrop;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.MonitoringFlags;
import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.db.Record.TrackedArtist;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import java.time.Instant;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class DatabaseServiceTest {
@Inject
DatabaseService db;
@BeforeEach
void cleanDb() {
for (var a : db.findAllArtists()) {
db.deleteArtistCascading(a.mbid());
}
}
@Test
void testInsertAndFindArtist() {
var artist = new TrackedArtist("mbid-1", "Test Artist", "", "UK",
true, false, false, false, false,
Instant.now(), Instant.now());
db.insertArtist(artist);
assertTrue(db.exists("mbid-1"));
var found = db.findByMbid("mbid-1");
assertTrue(found.isPresent());
assertEquals("Test Artist", found.get().name());
assertTrue(found.get().albumMonitored());
assertFalse(found.get().singleMonitored());
}
@Test
void testFindAllArtists() {
db.insertArtist(new TrackedArtist("a", "Artist A", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.insertArtist(new TrackedArtist("b", "Artist B", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
assertEquals(2, db.findAllArtists().size());
}
@Test
void testUpdateMonitoring() {
db.insertArtist(new TrackedArtist("m-1", "Test", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.updateMonitoring("m-1", new MonitoringFlags(false, true, false, false, false));
var artist = db.findByMbid("m-1").orElseThrow();
assertFalse(artist.albumMonitored());
assertTrue(artist.singleMonitored());
}
@Test
void testDeleteArtist() {
db.insertArtist(new TrackedArtist("del-1", "Delete Me", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
assertTrue(db.exists("del-1"));
db.deleteArtist("del-1");
assertFalse(db.exists("del-1"));
}
@Test
void testUpsertAndGetFeed() {
db.insertArtist(new TrackedArtist("feed-artist", "Feed Artist", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
var groups = List.of(
new ReleaseGroup("rg-1", "Album One", "Album", "[]", "2020-01-01", "feed-artist", "Feed Artist", Instant.now()),
new ReleaseGroup("rg-2", "Single One", "Single", "[]", "2021-01-01", "feed-artist", "Feed Artist", Instant.now())
);
db.upsertReleaseGroups(groups);
var types = Map.of("feed-artist", List.of("Album", "Single"));
var result = db.getFeed(types, 10, 0);
assertEquals(2, result.entries().size());
assertFalse(result.hasMore());
}
@Test
void testGetFeedWithPagination() {
db.insertArtist(new TrackedArtist("page-artist", "Page Artist", "", "", true, true, false, false, false, Instant.now(), Instant.now()));
var groups = new ArrayList<ReleaseGroup>();
for (int i = 1; i <= 15; i++) {
groups.add(new ReleaseGroup("rg-" + i, "Release " + i, i % 2 == 0 ? "Album" : "Single", "[]",
"2020-" + String.format("%02d", i % 12 + 1) + "-01", "page-artist", "Page Artist", Instant.now()));
}
db.upsertReleaseGroups(groups);
var types = Map.of("page-artist", List.of("Album", "Single"));
var page1 = db.getFeed(types, 10, 0);
assertEquals(10, page1.entries().size());
assertTrue(page1.hasMore());
var page2 = db.getFeed(types, 10, 10);
assertEquals(5, page2.entries().size());
assertFalse(page2.hasMore());
}
@Test
void testSettings() {
assertEquals("8", db.getSetting("cache_ttl_hours", "8"));
db.setSetting("cache_ttl_hours", "12");
assertEquals("12", db.getSetting("cache_ttl_hours", "8"));
assertEquals("default", db.getSetting("nonexistent", "default"));
}
@Test
void testGetStaleness() {
db.insertArtist(new TrackedArtist("stale-1", "Stale Artist", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.insertArtist(new TrackedArtist("stale-2", "Fresh Artist", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
var groups = List.of(
new ReleaseGroup("rg-s1", "Release", "Album", "[]", "2020-01-01", "stale-2", "Fresh Artist", Instant.now())
);
db.upsertReleaseGroups(groups);
var staleness = db.getStaleness(List.of("stale-1", "stale-2"), 1000);
assertTrue(staleness.get("stale-1"));
assertFalse(staleness.get("stale-2"));
}
@Test
void testDeleteArtistCascading() {
db.insertArtist(new TrackedArtist("cascade-1", "Cascade", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("rg-c1", "R1", "Album", "[]", "2020-01-01", "cascade-1", "Cascade", Instant.now())
));
assertEquals(1, db.countReleaseGroupsForArtist("cascade-1"));
db.deleteArtistCascading("cascade-1");
assertFalse(db.exists("cascade-1"));
assertEquals(0, db.countReleaseGroupsForArtist("cascade-1"));
}
@Test
void testFeedPerArtistFiltering() {
db.insertArtist(new TrackedArtist("art-a", "Artist A", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.insertArtist(new TrackedArtist("art-b", "Artist B", "", "", false, true, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("rg-a1", "A Album", "Album", "[]", "2020-01-01", "art-a", "Artist A", Instant.now()),
new ReleaseGroup("rg-b1", "B Single", "Single", "[]", "2020-02-01", "art-b", "Artist B", Instant.now()),
new ReleaseGroup("rg-b2", "B Album", "Album", "[]", "2020-03-01", "art-b", "Artist B", Instant.now())
));
var types = Map.of(
"art-a", List.of("Album"),
"art-b", List.of("Single")
);
var result = db.getFeed(types, 10, 0);
assertEquals(2, result.entries().size()); // A-Album + B-Single. B-Album excluded.
}
}
-87
View File
@@ -1,87 +0,0 @@
package com.discdrop;
import com.discdrop.client.dto.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DtoTest {
final ObjectMapper json = new ObjectMapper();
@Test
void testArtistSearchParse() throws Exception {
var input = "{\"artists\":[{\"id\":\"mbid-1\",\"name\":\"Test Artist\",\"disambiguation\":\"rock band\",\"area\":{\"name\":\"UK\"},\"type\":\"Group\",\"country\":\"GB\"}],\"count\":1}";
var result = json.readValue(input, ArtistSearchResponse.class);
assertEquals(1, result.count());
assertEquals("Test Artist", result.artists().get(0).name());
assertEquals("rock band", result.artists().get(0).disambiguation());
assertEquals("UK", result.artists().get(0).area().name());
assertEquals("Group", result.artists().get(0).type());
}
@Test
void testArtistSearchWithNoResults() throws Exception {
var input = "{\"artists\":[],\"count\":0}";
var result = json.readValue(input, ArtistSearchResponse.class);
assertTrue(result.artists().isEmpty());
assertEquals(0, result.count());
}
@Test
void testArtistDetailsParse() throws Exception {
var input = "{\"id\":\"mbid-d-1\",\"name\":\"Detail Artist\",\"disambiguation\":\"\",\"area\":{\"name\":\"US\"},\"type\":\"Person\",\"country\":\"US\",\"relations\":[]}";
var result = json.readValue(input, ArtistDetailsResponse.class);
assertEquals("Detail Artist", result.name());
assertEquals("US", result.area().name());
assertTrue(result.relations().isEmpty());
}
@Test
void testArtistDetailsWithRelations() throws Exception {
var input = "{\"id\":\"mbid-r-1\",\"name\":\"Related\",\"area\":null,\"type\":\"Group\",\"country\":\"DE\",\"relations\":[{\"type\":\"allmusic\",\"url\":{\"resource\":\"https://allmusic.com/artist/123\"}}]}";
var result = json.readValue(input, ArtistDetailsResponse.class);
assertEquals(1, result.relations().size());
assertEquals("allmusic", result.relations().get(0).type());
assertEquals("https://allmusic.com/artist/123", result.relations().get(0).url().resource());
}
@Test
void testReleaseGroupBrowseParse() throws Exception {
var input = "{\"release-groups\":[{\"id\":\"rg-1\",\"title\":\"Test Album\",\"first-release-date\":\"2020-01-15\",\"primary-type\":\"Album\",\"secondary-types\":[\"Live\"],\"artist-credit\":[{\"artist\":{\"id\":\"art-1\",\"name\":\"Test Artist\"}}]}],\"release-group-count\":1}";
var result = json.readValue(input, ReleaseGroupBrowseResponse.class);
assertEquals(1, result.releaseGroupCount());
var rg = result.releaseGroups().get(0);
assertEquals("Test Album", rg.title());
assertEquals("Album", rg.primaryType());
assertEquals("2020-01-15", rg.firstReleaseDate());
assertEquals(1, rg.secondaryTypes().size());
assertEquals("Live", rg.secondaryTypes().get(0));
assertEquals("Test Artist", rg.artistCredit().get(0).artist().name());
}
@Test
void testReleaseGroupBrowseEmpty() throws Exception {
var input = "{\"release-groups\":[],\"release-group-count\":0}";
var result = json.readValue(input, ReleaseGroupBrowseResponse.class);
assertTrue(result.releaseGroups().isEmpty());
assertEquals(0, result.releaseGroupCount());
}
@Test
void testUnknownPropertiesIgnored() throws Exception {
var input = "{\"id\":\"x\",\"name\":\"Test\",\"unknown_field\":true,\"area\":{\"name\":\"US\"},\"type\":\"Group\",\"country\":\"US\",\"relations\":[]}";
var result = json.readValue(input, ArtistDetailsResponse.class);
assertEquals("Test", result.name());
}
@Test
void testArtistSearchPartialData() throws Exception {
var input = "{\"artists\":[{\"id\":\"m-1\",\"name\":\"Minimal\"}],\"count\":1}";
var result = json.readValue(input, ArtistSearchResponse.class);
assertEquals("Minimal", result.artists().get(0).name());
assertNull(result.artists().get(0).area());
assertNull(result.artists().get(0).type());
}
}
@@ -1,106 +0,0 @@
package com.discdrop;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.db.Record.TrackedArtist;
import com.discdrop.service.FeedService;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class FeedServiceTest {
@Inject
FeedService feedService;
@Inject
DatabaseService db;
@BeforeEach
void cleanUp() {
for (var a : db.findAllArtists()) {
db.deleteArtistCascading(a.mbid());
}
db.setSetting("cache_ttl_hours", "100");
}
@Test
void testEmptyFeed() {
assertTrue(feedService.getFeedEntries(1).entries().isEmpty());
assertTrue(feedService.getAllFeedEntries().isEmpty());
}
@Test
void testFeedWithData() {
db.insertArtist(new TrackedArtist("feed-a", "Feed A", "", "",
true, true, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("feed-rg-1", "Album One", "Album", "[]", "2020-01-01", "feed-a", "Feed A", Instant.now()),
new ReleaseGroup("feed-rg-2", "Single One", "Single", "[]", "2020-02-01", "feed-a", "Feed A", Instant.now())
));
assertEquals(2, feedService.getAllFeedEntries().size());
}
@Test
void testPagination() {
db.insertArtist(new TrackedArtist("page-artist", "Page A", "", "",
true, true, true, false, false, Instant.now(), Instant.now()));
var groups = new java.util.ArrayList<ReleaseGroup>();
for (int i = 1; i <= 25; i++) {
groups.add(new ReleaseGroup("page-rg-" + i, "Rel " + i, i % 3 == 0 ? "EP" : (i % 2 == 0 ? "Single" : "Album"), "[]",
"2020-" + (i % 12 + 1) + "-01", "page-artist", "Page A", Instant.now()));
}
db.upsertReleaseGroups(groups);
var page1 = feedService.getFeedEntries(1);
assertEquals(10, page1.entries().size());
assertTrue(page1.hasMore());
var page3 = feedService.getFeedEntries(3);
assertEquals(5, page3.entries().size());
assertFalse(page3.hasMore());
}
@Test
void testMonitoringFiltersFeed() {
db.insertArtist(new TrackedArtist("mon-artist", "Monitored", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("mon-rg-1", "Album", "Album", "[]", "2020-01-01", "mon-artist", "Monitored", Instant.now()),
new ReleaseGroup("mon-rg-2", "Single", "Single", "[]", "2020-02-01", "mon-artist", "Monitored", Instant.now())
));
var feed = feedService.getAllFeedEntries();
assertEquals(1, feed.size());
assertEquals("Album", feed.get(0).primaryType());
}
@Test
void testNoMonitoredTypesShowsNothing() {
db.insertArtist(new TrackedArtist("none-artist", "None", "", "",
false, false, false, false, false, Instant.now(), Instant.now()));
db.upsertReleaseGroups(List.of(
new ReleaseGroup("none-rg-1", "Album", "Album", "[]", "2020-01-01", "none-artist", "None", Instant.now())
));
assertTrue(feedService.getAllFeedEntries().isEmpty());
}
@Test
void testFeedWithArtistThatNeedsRefresh() {
db.insertArtist(new TrackedArtist("mock-artist", "Mock", "", "",
true, false, false, false, false, Instant.now(), Instant.now()));
db.setSetting("cache_ttl_hours", "0");
var feed = feedService.getAllFeedEntries();
assertNotNull(feed);
}
}
@@ -1,50 +0,0 @@
package com.discdrop;
import com.discdrop.client.MusicBrainzClient;
import com.discdrop.client.dto.*;
import io.quarkus.test.Mock;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.util.List;
@Mock
@ApplicationScoped
@RestClient
public class MockMusicBrainz implements MusicBrainzClient {
@Override
public ArtistSearchResponse searchArtists(String query, int limit, String fmt) {
if (query == null || query.isBlank() || query.equals("artist:")) {
return new ArtistSearchResponse(List.of(), 0);
}
return new ArtistSearchResponse(
List.of(new ArtistSearchResponse.Artist(
"mock-mbid-1", "Mock Band", "a mock band",
new ArtistSearchResponse.Area("US"), "Group", "US")),
1
);
}
@Override
public ArtistDetailsResponse getArtistDetails(String mbid, String inc, String fmt) {
return new ArtistDetailsResponse(
mbid, "Mock Band", "a mock band",
new ArtistDetailsResponse.Area("US"), "Group", "US",
List.of()
);
}
@Override
public ReleaseGroupBrowseResponse browseReleaseGroups(String artistMbid, int limit, int offset, String inc, String fmt) {
return new ReleaseGroupBrowseResponse(
List.of(new ReleaseGroupBrowseResponse.ReleaseGroup(
"mock-rg-1", "Mock Album", "2020-01-15",
"Album", List.of("Studio"),
List.of(new ReleaseGroupBrowseResponse.ReleaseGroup.ArtistCredit(
new ReleaseGroupBrowseResponse.ReleaseGroup.ArtistCredit.Artist(artistMbid, "Mock Band")
))
)),
1
);
}
}
@@ -1,89 +0,0 @@
package com.discdrop;
import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.util.RSSFeedBuilder;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class RSSFeedBuilderTest {
@Inject
RSSFeedBuilder feedBuilder;
@Test
void testEmptyRss() {
var xml = feedBuilder.buildRss(List.of());
assertTrue(xml.contains("<title>DiscDrop"));
assertTrue(xml.contains("<rss"));
assertTrue(xml.contains("</rss>"));
}
@Test
void testRssWithEntries() {
var entries = List.of(
new ReleaseGroup("mbid-1", "Album One", "Album", "[]", "2020-01-15", "artist-1", "Artist One", Instant.now()),
new ReleaseGroup("mbid-2", "Single Two", "Single", "[\"Live\"]", "2021-06-01", "artist-2", "Artist Two", Instant.now())
);
var xml = feedBuilder.buildRss(entries);
assertTrue(xml.contains("Artist One"));
assertTrue(xml.contains("Album One"));
assertTrue(xml.contains("mbid-1"));
assertTrue(xml.contains("Artist Two"));
assertTrue(xml.contains("Single Two"));
assertTrue(xml.contains("mbid-2"));
assertTrue(xml.contains("Album"));
assertTrue(xml.contains("Single"));
assertTrue(xml.contains("2020-01-15"));
assertTrue(xml.contains("2021-06-01"));
assertTrue(xml.contains("coverartarchive"));
}
@Test
void testOpml() {
var opml = feedBuilder.buildOpml();
assertTrue(opml.contains("<opml"));
assertTrue(opml.contains("DiscDrop"));
assertTrue(opml.contains("feed/rss.xml"));
}
@Test
void testRssHasRequiredElements() {
var entries = List.of(
new ReleaseGroup("mbid-3", "Test Release", "Album", "[]", "2022-03-15", "artist-3", "Artist Three", Instant.now())
);
var xml = feedBuilder.buildRss(entries);
assertTrue(xml.contains("<guid"));
assertTrue(xml.contains("mbz-rg-mbid-3"));
assertTrue(xml.contains("<pubDate"));
assertTrue(xml.contains("<category"));
assertTrue(xml.contains("musicbrainz.org/release-group/mbid-3"));
}
@Test
void testRssHandlesMissingDate() {
var entries = List.of(
new ReleaseGroup("mbid-4", "No Date", "Album", "[]", null, "artist-4", "Artist Four", Instant.now())
);
var xml = feedBuilder.buildRss(entries);
assertTrue(xml.contains("Artist Four"));
}
@Test
void testRssEscapesHtml() {
var entries = List.of(
new ReleaseGroup("mbid-5", "Title & <More>", "Album", "[]", "2020-01-01", "artist-5", "Artist & Band", Instant.now())
);
var xml = feedBuilder.buildRss(entries);
assertTrue(xml.contains("&amp;") || xml.contains("&amp;amp;"));
assertTrue(xml.contains("&lt;") || xml.contains("&amp;lt;"));
}
}
@@ -1,7 +0,0 @@
quarkus.http.port=0
discdrop.db.path=target/test-discdrop.db
discdrop.cache.ttl-hours=100
discdrop.base-url=http://localhost:8080
discdrop.user-agent=DiscDrop/1.0 (test)