Compare commits

..

82 Commits

Author SHA1 Message Date
droideparanoico bfa0115f98 Added workflow
Build & Deploy / build (push) Failing after 36s
2026-07-07 16:30:00 +02:00
droideparanoico 7e3e1be47a Update README for multi-stage Docker build, add USER_AGENT env var 2026-07-07 16:26:53 +02:00
droideparanoico 34fcc54cad Multi-stage Dockerfile with layer caching, JVM tuning, volume, and .dockerignore 2026-07-07 16:16:17 +02:00
droideparanoico 56a4249e17 Make User-Agent configurable via env var DISC_DROP_USER_AGENT 2026-07-07 13:47:22 +02:00
droideparanoico 4b3c9c29fd Make User-Agent configurable via env var DISC_DROP_USER_AGENT 2026-07-07 13:35:18 +02:00
droideparanoico b8e150214b Register UserAgentFilter with MusicBrainzClient via @RegisterProvider 2026-07-07 13:33:48 +02:00
droideparanoico f406f6d291 Restore hx-swap-oob on artist list for search dropdown responses 2026-07-06 16:25:18 +02:00
droideparanoico b7d18e55de Fix: cache all release groups, filter by monitoring only at query time 2026-07-06 16:21:38 +02:00
droideparanoico 2062989127 Fix monitoring: remove OOB conflict, save to DB correctly 2026-07-06 16:07:26 +02:00
droideparanoico aab948ae08 Fix monitoring and removal: restore artist list OOB, improve feed reload script 2026-07-06 16:03:25 +02:00
droideparanoico ab9e686b9f Monitoring checkboxes target #feed-entries directly for instant refresh 2026-07-06 16:00:02 +02:00
droideparanoico 3677d55183 Refresh feed when toggling release type monitoring 2026-07-06 15:57:14 +02:00
droideparanoico a277d54484 Collapse search dropdown after adding artist (no empty results shown) 2026-07-06 15:45:46 +02:00
droideparanoico b228024e31 Increase dropdown padding to 1rem, replace Tailwind classes with inline styles 2026-07-06 15:44:06 +02:00
droideparanoico 06f118641e Merge remote-tracking branch 'origin/main'
# Conflicts:
#	src/main/resources/templates/layouts/base.html
2026-07-06 15:41:24 +02:00
droideparanoico 0e4c2df69a Fix input width with inline style 2026-07-06 15:41:07 +02:00
droideparanoico ac65576958 Fix input width with inline style 2026-07-06 15:40:10 +02:00
droideparanoico f986536807 Widen search box to 24rem 2026-07-06 15:39:25 +02:00
droideparanoico 14c1f3cb72 Auto-refresh feed when adding/removing artists via search 2026-07-06 15:36:21 +02:00
droideparanoico aa1234d363 Fix: read search query from both query param and form body 2026-07-06 15:30:48 +02:00
droideparanoico e577d58dd9 Fix Qute template escaping for hx-vals 2026-07-06 15:28:45 +02:00
droideparanoico 14ee3555ee Make Added button remove artist and refresh search results 2026-07-06 15:19:57 +02:00
droideparanoico 1e1ff42d17 Merge remote-tracking branch 'origin/main' 2026-07-06 13:42:54 +02:00
droideparanoico 9d72b36ada Merge remote-tracking branch 'origin/main' 2026-07-06 13:42:49 +02:00
droideparanoico 2398346474 Merge remote-tracking branch 'origin/main' 2026-07-06 13:42:35 +02:00
droideparanoico 4809a3736d Change +Add button to btn-accent for distinct visibility 2026-07-06 13:42:28 +02:00
droideparanoico 688b034ddf Change +Add button to btn-accent for distinct visibility 2026-07-06 13:26:24 +02:00
droideparanoico 1971f393af Match Added button size with +Add using btn-success btn-sm 2026-07-06 13:24:12 +02:00
droideparanoico 8979386551 Clear search results on click outside 2026-07-06 13:21:47 +02:00
droideparanoico 20ebd834ff Make artist name linkable to MusicBrainz artist page 2026-07-06 13:12:52 +02:00
droideparanoico 7933abc5cf Make Remove button square with btn-sm btn-square 2026-07-06 13:10:09 +02:00
droideparanoico 49e1efd363 Fix: per-artist monitoring filter instead of global type filter 2026-07-06 13:07:25 +02:00
droideparanoico 5997daaebc Fixed layout shift 2026-07-06 12:55:16 +02:00
droideparanoico adca5f071c Reduce feed page size to 10, update search placeholder 2026-07-06 11:59:56 +02:00
droideparanoico c25264c993 RSS feed returns all entries (unpaginated); db handles limit=0 as unlimited 2026-07-06 11:57:44 +02:00
droideparanoico bef4f7ba1d Add pagination to feed with Load more button using HTMX 2026-07-06 11:51:46 +02:00
droideparanoico 969fc673bc Move Artists link before search box 2026-07-03 22:03:02 +02:00
droideparanoico e53f3f30b4 Make DiscDrop use daisyUI btn component matching Artists 2026-07-03 22:02:19 +02:00
droideparanoico 98db921806 Revert "Make Artists a plain link to match DiscDrop style"
This reverts commit ab4dc3735e.
2026-07-03 22:01:13 +02:00
droideparanoico ab4dc3735e Make Artists a plain link to match DiscDrop style 2026-07-03 22:00:07 +02:00
droideparanoico daab557692 Restructure navbar: DiscDrop, search, Artists centered together 2026-07-03 13:54:26 +02:00
droideparanoico c06b22d27e Stack date above badge, both at 1.5rem 2026-07-03 13:48:25 +02:00
droideparanoico 53985b0cd2 Bump fonts: title 2rem, artist 1.5rem, badge/date 0.95-1rem 2026-07-03 13:44:00 +02:00
droideparanoico c36860258f Increase card font sizes: title 1.5rem, artist 1rem 2026-07-03 13:41:58 +02:00
droideparanoico abbc1693b2 Revert card width, increase card text font sizes 2026-07-03 13:40:19 +02:00
droideparanoico d290bc81bc Constrain card width to 42rem and center them 2026-07-03 13:38:16 +02:00
droideparanoico 076992525a Add custom shadow classes for cards and dropdown 2026-07-03 13:29:31 +02:00
droideparanoico 18251be3c1 Fix card gap: inline display:flex replaces non-functional Tailwind flex classes 2026-07-03 13:26:54 +02:00
droideparanoico 336791e355 Fix gap/sizing: use inline styles for Tailwind classes not in daisyUI CDN 2026-07-03 13:25:20 +02:00
droideparanoico 60bd9bb468 Increase card spacing from gap-6 to gap-10 2026-07-03 13:24:12 +02:00
droideparanoico 8d9a3d20bc Swap body (base-100) and cards idle (base-200); cards hover (base-300) matches navbar 2026-07-03 13:12:58 +02:00
droideparanoico cb30e9b6db Restore centered DiscDrop branding in navbar using daisyUI navbar-start/center/end 2026-07-03 13:08:49 +02:00
droideparanoico 24c5849f05 Fix CSS variables: use daisyUI classes (bg-base-*, text-base-content/*) which wrap OKLCH values in proper oklch() function 2026-07-03 13:03:59 +02:00
droideparanoico 5eb4cd340c Replace all CSS variables with explicit hex colors for guaranteed distinction 2026-07-03 12:59:35 +02:00
droideparanoico 41f42acd91 Use color-mix for clearly distinct background tones
- Body: var(--b1) (baseline)
- Cards: color-mix(var(--b1) + 8% white) = lighter surface
- Navbar: color-mix(var(--b1) + 20% black) = darker header
- Cards hover: same as navbar
- Search dropdown: matches navbar darkness
2026-07-03 12:47:30 +02:00
droideparanoico df5f616bc3 Fix bg hierarchy: body --b1 (lightest), cards --b2, navbar+cards hover --b3 (darkest) 2026-07-03 12:42:28 +02:00
droideparanoico ba2db960c6 Fix background hierarchy: body --b1 (lightest), navbar+cards idle --b2, cards hover --b3 (darkest) 2026-07-03 12:39:43 +02:00
droideparanoico b6e57db54b Fix navbar layout, card spacing, and background hierarchy
- Restore centered DiscDrop branding in navbar
- Navbar bg: base-300 (darkest)
- Cards bg: base-200 (intermediate), hover: base-300 (matches navbar)
- Body bg: base-100 (lightest)
- Card gap increased from gap-4 to gap-6
2026-07-03 12:33:35 +02:00
droideparanoico 508e4e6114 Fix background hierarchy: body base-100 (lightest), navbar base-200, cards base-100 with border, hover base-200 2026-07-03 12:28:29 +02:00
droideparanoico 882914d271 Fix settings save: use @FormParam instead of @QueryParam for PATCH body 2026-07-03 12:21:29 +02:00
droideparanoico e9be57bde0 Revert "Strip all custom CSS, use plain daisyUI classes only"
This reverts commit e2bd237a7b.
2026-07-03 12:18:52 +02:00
droideparanoico e2bd237a7b Strip all custom CSS, use plain daisyUI classes only 2026-07-03 12:16:02 +02:00
droideparanoico 96c2a579ba Fix backgrounds: body uses inline var(--b2), cards use var(--b1) with var(--b3) borders 2026-07-03 12:08:35 +02:00
droideparanoico e18296d88a Revert cards to base-100 with border+shadow for distinction 2026-07-03 12:06:19 +02:00
droideparanoico 18b3d8a30d Feed cards use base-200 background to contrast with navbar 2026-07-03 12:04:14 +02:00
droideparanoico d8320ad6d6 Feed: use 250px cover art size 2026-07-03 12:01:22 +02:00
droideparanoico e4806db04d Feed: square 140px cover with details to the right 2026-07-03 11:59:32 +02:00
droideparanoico e3f20166a4 Redesign feed: large cover with details below
- Each item is a full-width card with cover at 3:1 aspect ratio
- Artist name, title, type badge, and date below the cover
- Subtle hover lift effect
2026-07-03 11:29:03 +02:00
droideparanoico 9a0823854e Change feed to vertical list with horizontal cards
- Replaced multi-column grid with single vertical list (flex-direction:column)
- Each item is a horizontal card: cover (100px) on the left, details on the right
- Newest first (DESC order with NULLS LAST)
2026-07-03 11:26:51 +02:00
droideparanoico edd9b99b46 Fix navbar layout and feed ordering
- Replace daisyUI navbar sections with custom flex layout
- Branding centered: simple solid-color text (no gradient tricks)
- Tabs on the left, search + theme + settings on the right
- Search wrapper has max-width 420px, flex-shrink parent prevents overflow
- Feed ORDER BY adds NULLS LAST handling for empty dates
2026-07-03 11:23:24 +02:00
droideparanoico 695d46259d Fix search, branding, header centering, and card-based feed layout
- Search box now fills navbar center with max-width 400px
- Dropdown items have more padding (p-4) and hover contrast
- Branding uses cross-browser gradient text with visible tagline
- Navbar centers tabs + search together in navbar-center
- Feed redesigned as responsive card grid (cover-centered, details below)
- Cards show large cover art with aspect-ratio:1, fallback SVG on 404
- Artist name, title, type badge, and date positioned under cover
2026-07-03 11:12:58 +02:00
droideparanoico d66b6e884f Fix UI: replace Tailwind classes with inline styles for CDN compatibility 2026-07-03 11:04:41 +02:00
droideparanoico 16f23557f0 UI polish: centering, search width, dropdown contrast, cover thumbnails, branding, sticky headers
- Constrain main content to max-w-5xl centered
- Widen search input (w-72 md:w-80 lg:w-96)
- Search dropdown: card with shadow, border, and primary-tinted hover
- Branding: SVG music note icon + gradient text + tagline
- Covers: 64px with front-250 thumbnail URL for faster loading
- Badge colors per type (primary/secondary/accent/ghost)
- Sticky table headers via table-pin-rows
- SVG icons for Add/Remove/RSS/OPML buttons
- Consistent hover transitions across tables
2026-07-03 10:58:44 +02:00
droideparanoico 0bae5fd288 Fix cover art sizing with explicit width/height styles 2026-07-02 21:52:35 +02:00
droideparanoico 15c153c56a 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
droideparanoico 010943e587 Fix MB API deserialization: primary-type and secondary-types are strings not objects 2026-07-02 16:29:48 +02:00
droideparanoico 08aa5b8a2d 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
droideparanoico b08eb2573c 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
26 changed files with 72 additions and 1130 deletions
-3
View File
@@ -9,6 +9,3 @@ data/
# OS
.DS_Store
# Gitea
.gitea/
-3
View File
@@ -9,9 +9,6 @@ on:
push:
branches: [ main ]
env:
IMAGE_NAME: discdrop
jobs:
build:
runs-on: ubuntu-latest
-1
View File
@@ -11,4 +11,3 @@ data/
*.jar
*.log
.DS_Store
.gitea/
+1
View File
@@ -11,6 +11,7 @@ RUN mvn package -q -DskipTests
# ─── 2. Runtime ───────────────────────────────────
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN apk add --no-cache sqlite
COPY --from=build /build/target/quarkus-app/ /app/
EXPOSE 8080
-6
View File
@@ -60,12 +60,6 @@ docker run -p 8080:8080 \
-e DISC_DROP_BASE_URL=http://localhost:8080 \
-v discdrop-data:/app/data \
discdrop
### Docker Compose
```bash
docker compose up -d
```
```
---
-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 -32
View File
@@ -30,22 +30,22 @@
</dependencyManagement>
<dependencies>
<!-- REST server (reactive) -->
<!-- REST server (resteasy-reactive successor) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
<artifactId>quarkus-resteasy</artifactId>
</dependency>
<!-- Qute templating -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-qute</artifactId>
<artifactId>quarkus-resteasy-qute</artifactId>
</dependency>
<!-- REST Client -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client</artifactId>
<artifactId>quarkus-resteasy-client</artifactId>
</dependency>
<!-- CDI -->
@@ -75,7 +75,7 @@
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
<artifactId>quarkus-resteasy-client-jackson</artifactId>
</dependency>
<!-- Test -->
@@ -90,37 +90,10 @@
<version>5.5.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<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>
<configuration>
<excludes>
<exclude>com/discdrop/client/dto/*</exclude>
<exclude>com/discdrop/client/MusicBrainzClient.class</exclude>
<exclude>com/discdrop/client/UserAgentFilter.class</exclude>
<exclude>com/discdrop/service/ArtistService.class</exclude>
<exclude>com/discdrop/service/ReleaseGroupService.class</exclude>
<exclude>com/discdrop/web/ArtistController.class</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
@@ -3,15 +3,10 @@ package com.discdrop.db;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.sqlite.SQLiteDataSource;
import jakarta.annotation.PostConstruct;
@@ -89,7 +84,7 @@ public class DatabaseService {
public List<Record.TrackedArtist> findAllArtists() {
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();
Statement stmt = conn.createStatement();
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 ---
public void upsertReleaseGroups(List<Record.ReleaseGroup> groups) {
@@ -241,24 +213,16 @@ public class DatabaseService {
public record FeedResult(List<Record.ReleaseGroup> entries, boolean hasMore) {}
public FeedResult getFeed(Map<String, List<String>> artistTypes, int limit, int offset) {
public FeedResult getFeed(List<String> artistMbids, List<String> primaryTypes, int limit, int offset) {
var groups = new ArrayList<Record.ReleaseGroup>();
if (artistTypes.isEmpty()) return new FeedResult(groups, false);
if (artistMbids.isEmpty()) return new FeedResult(groups, false);
var conditions = new ArrayList<String>();
var params = new ArrayList<Object>();
for (var entry : artistTypes.entrySet()) {
var types = entry.getValue();
if (types.isEmpty()) continue;
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);
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 + ")";
}
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;
@@ -267,10 +231,8 @@ public class DatabaseService {
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
int idx = 1;
for (var p : params) {
if (p instanceof String s) ps.setString(idx++, s);
else ps.setObject(idx++, p);
}
for (var mbid : artistMbids) ps.setString(idx++, mbid);
for (var type : primaryTypes) ps.setString(idx++, type);
if (useLimit) {
ps.setInt(idx++, limit + 1);
ps.setInt(idx, offset);
@@ -329,41 +291,6 @@ public class DatabaseService {
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 ---
public String getSetting(String key, String defaultValue) {
@@ -84,7 +84,8 @@ public class ArtistService {
}
public void removeArtist(String mbid) {
db.deleteArtistCascading(mbid);
db.deleteReleaseGroupsForArtist(mbid);
db.deleteArtist(mbid);
}
public void updateMonitoring(String mbid, MonitoringFlags flags) {
@@ -5,10 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@ApplicationScoped
public class CacheService {
@@ -28,15 +25,12 @@ public class CacheService {
public boolean isStale(String artistMbid) {
Optional<Instant> lastCached = db.getLastCachedAt(artistMbid);
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) {
return db.countReleaseGroupsForArtist(artistMbid) == 0 || isStale(artistMbid);
}
public Map<String, Boolean> needsRefresh(List<String> artistMbids) {
var staleness = db.getStaleness(artistMbids, getTtlHours());
return staleness;
}
}
@@ -6,11 +6,8 @@ import com.discdrop.db.Record.ReleaseGroup;
import com.discdrop.db.Record.TrackedArtist;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.List;
@ApplicationScoped
public class FeedService {
@@ -32,36 +29,55 @@ public class FeedService {
public List<ReleaseGroup> getAllFeedEntries() {
var artists = artistService.findAll();
if (artists.isEmpty()) return List.of();
refreshStaleCaches(artists);
return db.getFeed(buildArtistTypesMap(artists), 0, 0).entries();
var artistMbids = artistMbidsWithRefresh(artists);
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
var allEntries = db.getFeed(artistMbids, allTypes, 0, 0).entries();
return filterByArtistMonitoring(allEntries, artists);
}
public FeedResult getFeedEntries(int page) {
var artists = artistService.findAll();
if (artists.isEmpty()) return new FeedResult(List.of(), false);
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) {
var types = getMonitoredTypes(a);
if (!types.isEmpty()) map.put(a.mbid(), types);
}
return map;
}
private void refreshStaleCaches(List<TrackedArtist> artists) {
var artistMbids = artistMbidsWithRefresh(artists);
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
var mbids = artists.stream().map(TrackedArtist::mbid).toList();
var staleness = cacheService.needsRefresh(mbids);
var allEntries = db.getFeed(artistMbids, allTypes, 0, 0).entries();
var filtered = filterByArtistMonitoring(allEntries, artists);
var fromIndex = (page - 1) * PAGE_SIZE;
if (fromIndex >= filtered.size()) return new FeedResult(List.of(), false);
var toIndex = Math.min(fromIndex + PAGE_SIZE, filtered.size());
var pageEntries = filtered.subList(fromIndex, toIndex);
var hasMore = toIndex < filtered.size();
return new FeedResult(pageEntries, hasMore);
}
private List<String> artistMbidsWithRefresh(List<TrackedArtist> artists) {
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
var mbids = new ArrayList<String>();
for (var a : artists) {
if (staleness.getOrDefault(a.mbid(), false)) {
if (cacheService.needsRefresh(a.mbid())) {
releaseGroupService.fetchAndCache(a.mbid(), a.name(), allTypes);
}
mbids.add(a.mbid());
}
return mbids;
}
private List<ReleaseGroup> filterByArtistMonitoring(List<ReleaseGroup> entries, List<TrackedArtist> artists) {
var flags = new java.util.HashMap<String, java.util.Set<String>>();
for (var a : artists) {
flags.put(a.mbid(), java.util.Set.copyOf(getMonitoredTypes(a)));
}
var result = new ArrayList<ReleaseGroup>();
for (var e : entries) {
var artistFlags = flags.get(e.artistMbid());
if (artistFlags != null && artistFlags.contains(e.primaryType())) {
result.add(e);
}
}
return result;
}
private List<String> getMonitoredTypes(TrackedArtist a) {
@@ -1,12 +1,7 @@
package com.discdrop.util;
import com.discdrop.db.Record.ReleaseGroup;
import com.rometools.rome.feed.synd.SyndCategoryImpl;
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.feed.synd.*;
import com.rometools.rome.io.SyndFeedOutput;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@@ -1,5 +1,6 @@
package com.discdrop.web;
import com.discdrop.db.DatabaseService.FeedResult;
import com.discdrop.db.Record.MonitoringFlags;
import com.discdrop.db.Record.TrackedArtist;
import com.discdrop.service.ArtistService;
@@ -10,18 +11,7 @@ import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
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.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
@@ -113,15 +103,12 @@ public class ArtistController {
@QueryParam("type") String type) {
var existing = artistService.findByMbid(mbid)
.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(
"album".equals(type) ? !existing.albumMonitored() : existing.albumMonitored(),
"single".equals(type) ? !existing.singleMonitored() : existing.singleMonitored(),
"ep".equals(type) ? !existing.epMonitored() : existing.epMonitored(),
"broadcast".equals(type) ? !existing.broadcastMonitored() : existing.broadcastMonitored(),
"other".equals(type) ? !existing.otherMonitored() : existing.otherMonitored()
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();
@@ -5,13 +5,7 @@ import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
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.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
@@ -42,18 +36,6 @@ public class SettingsController {
public Response saveSettings(
@FormParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl,
@FormParam("default_types") List<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("default_types", defaultTypes != null ? String.join(",", defaultTypes) : "Album");
return Response.ok("<div class=\"alert alert-success\">Settings saved</div>")
+1 -1
View File
@@ -17,4 +17,4 @@ discdrop.cache.ttl-hours=${DISC_DROP_CACHE_TTL:8}
discdrop.base-url=${DISC_DROP_BASE_URL:http://localhost:8080}
# User-Agent sent to MusicBrainz API (env: DISC_DROP_USER_AGENT)
discdrop.user-agent=${DISC_DROP_USER_AGENT:DiscDrop/1.0 (https://github.com/droideparanoico/discdrop)}
discdrop.user-agent=${DISC_DROP_USER_AGENT:DiscDrop/0.1 (https://https://github.com/droideparanoico/discdrop)}
@@ -77,7 +77,7 @@
hx-delete="/api/artists/{artist.mbid}"
hx-target="#artist-list"
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>
</button>
</td>
@@ -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,85 +0,0 @@
package com.discdrop;
import com.discdrop.db.DatabaseService;
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 static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class ArtistControllerExtendedTest {
@Inject
DatabaseService db;
@BeforeEach
void cleanUp() {
for (var a : db.findAllArtists()) {
db.deleteArtistCascading(a.mbid());
}
}
@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);
var artist = db.findByMbid("mon-ts").orElseThrow();
assertTrue(artist.singleMonitored());
assertTrue(artist.albumMonitored());
}
@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);
var artist = db.findByMbid("mon-ta").orElseThrow();
assertFalse(artist.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);
}
}
@@ -1,126 +0,0 @@
package com.discdrop;
import com.discdrop.db.DatabaseService;
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 static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@QuarkusTest
class ArtistControllerTest {
@Inject
DatabaseService db;
@BeforeEach
void cleanUp() {
for (var a : db.findAllArtists()) {
db.deleteArtistCascading(a.mbid());
}
}
@Test
void testSearchEndpointEmpty() {
given()
.queryParam("q", "")
.when().get("/api/artists/search")
.then()
.statusCode(200)
.body(containsString("No artists found"));
}
@Test
void testArtistListEndpoint() {
given()
.when().get("/api/artists/list")
.then()
.statusCode(200)
.body(containsString("artist-list"));
}
@Test
void testSettingsForm() {
given()
.when().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")
.when().patch("/api/settings")
.then()
.statusCode(200)
.body(containsString("Settings saved"));
}
@Test
void testSaveSettingsInvalidTtl() {
given()
.formParam("cache_ttl_hours", "invalid")
.formParam("default_types", "Album")
.when().patch("/api/settings")
.then()
.statusCode(400);
}
@Test
void testFeedTableEndpoint() {
given()
.when().get("/api/artists/feed-table")
.then()
.statusCode(200);
}
@Test
void testFeedTableWithPage() {
given()
.queryParam("page", "1")
.when().get("/api/artists/feed-table")
.then()
.statusCode(200);
}
@Test
void testReleasesPage() {
given()
.when().get("/")
.then()
.statusCode(200)
.body(containsString("DiscDrop"));
}
@Test
void testArtistsPage() {
given()
.when().get("/artists")
.then()
.statusCode(200);
}
@Test
void testRssFeed() {
given()
.when().get("/feed/rss.xml")
.then()
.statusCode(200)
.contentType(ContentType.XML);
}
@Test
void testOpml() {
given()
.when().get("/feed/opml")
.then()
.statusCode(200)
.contentType(ContentType.XML);
}
}
@@ -1,76 +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 testAddArtistWithDefaultMonitoring() {
var artist = new TrackedArtist("add-1", "Add Artist", "", "US",
true, false, false, false, false, Instant.now(), Instant.now());
db.insertArtist(artist);
assertTrue(db.exists("add-1"));
var found = db.findByMbid("add-1");
assertTrue(found.isPresent());
assertEquals("Add Artist", found.get().name());
}
@Test
void testFindByMbidNotFound() {
assertTrue(artistService.findByMbid("nonexistent").isEmpty());
}
@Test
void testFindAll() {
db.insertArtist(new TrackedArtist("all-1", "First", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
db.insertArtist(new TrackedArtist("all-2", "Second", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
assertEquals(2, artistService.findAll().size());
}
@Test
void testRemoveArtist() {
db.insertArtist(new TrackedArtist("rem-1", "Remove Me", "", "", true, false, false, false, false, Instant.now(), Instant.now()));
assertTrue(db.exists("rem-1"));
artistService.removeArtist("rem-1");
assertFalse(db.exists("rem-1"));
}
@Test
void testUpdateMonitoring() {
db.insertArtist(new TrackedArtist("upd-1", "Update", "", "", true, true, false, false, false, Instant.now(), Instant.now()));
artistService.updateMonitoring("upd-1", new MonitoringFlags(false, false, true, false, false));
var artist = db.findByMbid("upd-1").orElseThrow();
assertFalse(artist.albumMonitored());
assertFalse(artist.singleMonitored());
assertTrue(artist.epMonitored());
}
}
@@ -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,100 +0,0 @@
package com.discdrop;
import com.discdrop.service.FeedService;
import com.discdrop.service.CacheService;
import com.discdrop.db.DatabaseService;
import com.discdrop.db.Record.TrackedArtist;
import com.discdrop.db.Record.ReleaseGroup;
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
CacheService cacheService;
@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());
}
}
@@ -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)