Compare commits
6 Commits
1febd26aa3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f25a10cf8e | |||
| 6b9834ea44 | |||
| 334d7a834e | |||
| 0766b6352e | |||
| 03a2aa2987 | |||
| ba82c0b6ff |
@@ -0,0 +1,14 @@
|
|||||||
|
# Prevent local build artifacts from leaking into Docker
|
||||||
|
target/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
.gitea/
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# This workflow builds and deploys DiscDrop whenever code is pushed to main.
|
||||||
|
# The runner has Docker socket access so it can manage containers on the host.
|
||||||
|
# See: https://docs.gitea.com/usage/actions
|
||||||
|
|
||||||
|
name: Build & Deploy
|
||||||
|
run-name: Build ${{ gitea.sha.substring(0, 7) }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: discdrop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
|
||||||
|
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
- name: Clean up old images
|
||||||
|
run: docker image prune -f --filter "until=24h"
|
||||||
@@ -11,3 +11,4 @@ data/
|
|||||||
*.jar
|
*.jar
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.gitea/
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# 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"]
|
||||||
@@ -49,18 +49,23 @@ java -jar target/quarkus-app/quarkus-run.jar
|
|||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
Multi-stage build — no host pre-build needed, Maven runs inside the container.
|
||||||
# Build the JAR
|
|
||||||
mvn clean package
|
|
||||||
|
|
||||||
# Build the image (from project root)
|
```bash
|
||||||
docker build -f src/main/docker/Dockerfile.jvm -t discdrop .
|
# Build (from project root)
|
||||||
|
docker build -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
|
||||||
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -72,10 +77,11 @@ 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 (how often release groups are refreshed from MusicBrainz) |
|
| `DISC_DROP_CACHE_TTL` | `8` | Cache TTL in hours |
|
||||||
| `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 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).
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
discdrop:
|
||||||
|
build: .
|
||||||
|
container_name: discdrop
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- discdrop-data:/app/data
|
||||||
|
environment:
|
||||||
|
- DISC_DROP_BASE_URL=http://localhost:8080
|
||||||
|
- DISC_DROP_CACHE_TTL=8
|
||||||
|
- DISC_DROP_DB_PATH=/app/data/discdrop.db
|
||||||
|
- DISC_DROP_USER_AGENT=DiscDrop/1.0 (https://gitea.t3du.com/droideparanoico/discdrop)
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
discdrop-data:
|
||||||
@@ -30,22 +30,22 @@
|
|||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- REST server (resteasy-reactive successor) -->
|
<!-- REST server (reactive) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-resteasy</artifactId>
|
<artifactId>quarkus-rest</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Qute templating -->
|
<!-- Qute templating -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-resteasy-qute</artifactId>
|
<artifactId>quarkus-rest-qute</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- REST Client -->
|
<!-- REST Client -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-resteasy-client</artifactId>
|
<artifactId>quarkus-rest-client</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- CDI -->
|
<!-- CDI -->
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-resteasy-client-jackson</artifactId>
|
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Test -->
|
<!-- Test -->
|
||||||
@@ -84,16 +84,46 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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,10 +7,12 @@ 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,16 +1,21 @@
|
|||||||
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",
|
requestContext.getHeaders().putSingle("User-Agent", userAgent);
|
||||||
"DiscDrop/1.0 (david.alvarez.81@gmail.com)");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ 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.*;
|
import java.sql.Connection;
|
||||||
|
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;
|
||||||
@@ -84,7 +89,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 * FROM tracked_artist ORDER BY name";
|
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";
|
||||||
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)) {
|
||||||
@@ -184,6 +189,29 @@ 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) {
|
||||||
@@ -211,23 +239,42 @@ public class DatabaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Record.ReleaseGroup> getFeed(List<String> artistMbids, List<String> primaryTypes) {
|
public record FeedResult(List<Record.ReleaseGroup> entries, boolean hasMore) {}
|
||||||
var groups = new ArrayList<Record.ReleaseGroup>();
|
|
||||||
if (artistMbids.isEmpty()) return groups;
|
|
||||||
|
|
||||||
var placeholders = String.join(",", artistMbids.stream().map(m -> "?").toList());
|
public FeedResult getFeed(Map<String, List<String>> artistTypes, int limit, int offset) {
|
||||||
var sql = "SELECT * FROM release_group_cache WHERE artist_mbid IN (" + placeholders + ")";
|
var groups = new ArrayList<Record.ReleaseGroup>();
|
||||||
if (!primaryTypes.isEmpty()) {
|
if (artistTypes.isEmpty()) return new FeedResult(groups, false);
|
||||||
var typePlaceholders = String.join(",", primaryTypes.stream().map(t -> "?").toList());
|
|
||||||
sql += " AND primary_type IN (" + typePlaceholders + ")";
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
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 mbid : artistMbids) ps.setString(idx++, mbid);
|
for (var p : params) {
|
||||||
for (var type : primaryTypes) ps.setString(idx++, type);
|
if (p instanceof String s) ps.setString(idx++, s);
|
||||||
|
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));
|
||||||
@@ -236,7 +283,9 @@ public class DatabaseService {
|
|||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
return groups;
|
boolean hasMore = useLimit && groups.size() > limit;
|
||||||
|
if (hasMore) groups.remove(groups.size() - 1);
|
||||||
|
return new FeedResult(groups, hasMore);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteReleaseGroupsForArtist(String mbid) {
|
public void deleteReleaseGroupsForArtist(String mbid) {
|
||||||
@@ -280,6 +329,41 @@ 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,8 +84,7 @@ public class ArtistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void removeArtist(String mbid) {
|
public void removeArtist(String mbid) {
|
||||||
db.deleteReleaseGroupsForArtist(mbid);
|
db.deleteArtistCascading(mbid);
|
||||||
db.deleteArtist(mbid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateMonitoring(String mbid, MonitoringFlags flags) {
|
public void updateMonitoring(String mbid, MonitoringFlags flags) {
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ 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 {
|
||||||
@@ -25,12 +28,15 @@ 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,12 +1,16 @@
|
|||||||
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.ArrayList;
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class FeedService {
|
public class FeedService {
|
||||||
@@ -23,26 +27,41 @@ public class FeedService {
|
|||||||
@Inject
|
@Inject
|
||||||
DatabaseService db;
|
DatabaseService db;
|
||||||
|
|
||||||
public List<ReleaseGroup> getFeedEntries() {
|
public static final int PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
var artistMbids = new ArrayList<String>();
|
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) {
|
for (var a : artists) {
|
||||||
if (cacheService.needsRefresh(a.mbid())) {
|
|
||||||
var types = getMonitoredTypes(a);
|
var types = getMonitoredTypes(a);
|
||||||
releaseGroupService.fetchAndCache(a.mbid(), a.name(), types);
|
if (!types.isEmpty()) map.put(a.mbid(), types);
|
||||||
}
|
}
|
||||||
artistMbids.add(a.mbid());
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void refreshStaleCaches(List<TrackedArtist> artists) {
|
||||||
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
|
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
|
||||||
var monitoredTypes = new ArrayList<String>();
|
var mbids = artists.stream().map(TrackedArtist::mbid).toList();
|
||||||
|
var staleness = cacheService.needsRefresh(mbids);
|
||||||
for (var a : artists) {
|
for (var a : artists) {
|
||||||
monitoredTypes.addAll(getMonitoredTypes(a));
|
if (staleness.getOrDefault(a.mbid(), false)) {
|
||||||
|
releaseGroupService.fetchAndCache(a.mbid(), a.name(), allTypes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.getFeed(artistMbids, monitoredTypes.isEmpty() ? allTypes : monitoredTypes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> getMonitoredTypes(TrackedArtist a) {
|
private List<String> getMonitoredTypes(TrackedArtist a) {
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ 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,7 +1,12 @@
|
|||||||
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.*;
|
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.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;
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ 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.*;
|
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.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;
|
||||||
@@ -58,12 +69,10 @@ 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);
|
||||||
var types = getMonitoredTypes(artist);
|
releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), List.of("Album", "Single", "EP", "Broadcast", "Other"));
|
||||||
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();
|
||||||
var emptySearch = searchResultsTemplate.data("artists", List.of()).render();
|
return Response.ok(listHtml + feedReloadScript()).build();
|
||||||
return Response.ok(emptySearch + listHtml).build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@@ -75,6 +84,27 @@ 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)
|
||||||
@@ -83,12 +113,15 @@ 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(
|
||||||
type.equals("album") ? !existing.albumMonitored() : existing.albumMonitored(),
|
"album".equals(type) ? !existing.albumMonitored() : existing.albumMonitored(),
|
||||||
type.equals("single") ? !existing.singleMonitored() : existing.singleMonitored(),
|
"single".equals(type) ? !existing.singleMonitored() : existing.singleMonitored(),
|
||||||
type.equals("ep") ? !existing.epMonitored() : existing.epMonitored(),
|
"ep".equals(type) ? !existing.epMonitored() : existing.epMonitored(),
|
||||||
type.equals("broadcast") ? !existing.broadcastMonitored() : existing.broadcastMonitored(),
|
"broadcast".equals(type) ? !existing.broadcastMonitored() : existing.broadcastMonitored(),
|
||||||
type.equals("other") ? !existing.otherMonitored() : existing.otherMonitored()
|
"other".equals(type) ? !existing.otherMonitored() : existing.otherMonitored()
|
||||||
);
|
);
|
||||||
artistService.updateMonitoring(mbid, flags);
|
artistService.updateMonitoring(mbid, flags);
|
||||||
var allArtists = artistService.findAll();
|
var allArtists = artistService.findAll();
|
||||||
@@ -106,9 +139,12 @@ public class ArtistController {
|
|||||||
@GET
|
@GET
|
||||||
@Path("/feed-table")
|
@Path("/feed-table")
|
||||||
@Produces(MediaType.TEXT_HTML)
|
@Produces(MediaType.TEXT_HTML)
|
||||||
public TemplateInstance feedTable() {
|
public TemplateInstance feedTable(@QueryParam("page") @DefaultValue("1") int page) {
|
||||||
var entries = feedService.getFeedEntries();
|
var result = feedService.getFeedEntries(page);
|
||||||
return feedEntriesTemplate.data("entries", entries);
|
return feedEntriesTemplate
|
||||||
|
.data("entries", result.entries())
|
||||||
|
.data("hasMore", result.hasMore())
|
||||||
|
.data("nextPage", page + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> getMonitoredTypes(TrackedArtist a) {
|
private List<String> getMonitoredTypes(TrackedArtist a) {
|
||||||
@@ -120,4 +156,8 @@ 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.getFeedEntries();
|
var entries = feedService.getAllFeedEntries();
|
||||||
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,7 +5,13 @@ 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.*;
|
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.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -36,6 +42,18 @@ public class SettingsController {
|
|||||||
public Response saveSettings(
|
public Response saveSettings(
|
||||||
@FormParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl,
|
@FormParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl,
|
||||||
@FormParam("default_types") List<String> defaultTypes) {
|
@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("cache_ttl_hours", cacheTtl);
|
||||||
db.setSetting("default_types", defaultTypes != null ? String.join(",", defaultTypes) : "Album");
|
db.setSetting("default_types", defaultTypes != null ? String.join(",", defaultTypes) : "Album");
|
||||||
return Response.ok("<div class=\"alert alert-success\">Settings saved</div>")
|
return Response.ok("<div class=\"alert alert-success\">Settings saved</div>")
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ 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)}
|
||||||
|
|||||||
@@ -15,14 +15,14 @@
|
|||||||
<th>Artist</th>
|
<th>Artist</th>
|
||||||
<th>Since</th>
|
<th>Since</th>
|
||||||
<th>Release Types</th>
|
<th>Release Types</th>
|
||||||
<th style="width:6rem">Actions</th>
|
<th style="width:6rem">Remove</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 style="transition:background 0.15s" onmouseover="this.style.background='var(--fallback-b2,oklch(var(--b2)/1))'" onmouseout="this.style.background=''">
|
||||||
<td>
|
<td>
|
||||||
<div style="font-weight:500">{artist.name}</div>
|
<a href="https://musicbrainz.org/artist/{artist.mbid}" target="_blank" rel="noopener noreferrer" style="font-weight:500;text-decoration:none;color:inherit">{artist.name}</a>
|
||||||
{#if artist.disambiguation}
|
{#if artist.disambiguation}
|
||||||
<div class="text-xs text-base-content/50">{artist.disambiguation}</div>
|
<div class="text-xs text-base-content/50">{artist.disambiguation}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -73,13 +73,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-ghost btn-xs" style="color:var(--er)"
|
<button class="btn btn-ghost btn-sm btn-square" style="color:var(--er)"
|
||||||
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"
|
||||||
onclick="return confirm('Remove {artist.name}?')">
|
hx-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>
|
<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>
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
<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}
|
||||||
<div style="display:flex;flex-direction:column;gap:2.5rem">
|
{#for entry in entries}
|
||||||
{#for entry in entries}
|
<a href="https://musicbrainz.org/release-group/{entry.mbid}" target="_blank" rel="noopener noreferrer"
|
||||||
<a href="https://musicbrainz.org/release-group/{entry.mbid}" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="card card-side bg-base-200 border border-base-300 hover:bg-base-300 card-shadow overflow-hidden">
|
class="card card-side bg-base-200 border border-base-300 hover:bg-base-300 card-shadow overflow-hidden">
|
||||||
<figure class="w-64">
|
<figure class="w-64">
|
||||||
<img src="https://coverartarchive.org/release-group/{entry.mbid}/front-250"
|
<img src="https://coverartarchive.org/release-group/{entry.mbid}/front-250"
|
||||||
@@ -34,7 +33,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/for}
|
{/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,26 +1,31 @@
|
|||||||
<div class="card bg-base-300 dropdown-shadow border border-base-300 mt-1" style="max-height:18rem;overflow-y:auto">
|
<div class="card bg-base-300 dropdown-shadow border border-base-300 mt-1" style="max-height:18rem;overflow-y:auto">
|
||||||
{#if artists.isEmpty()}
|
{#if artists.isEmpty()}
|
||||||
<div class="p-4 text-sm text-center text-base-content/50">No artists found</div>
|
<div style="padding:1rem;text-align:center;font-size:0.875rem;color:var(--fallback-bc,oklch(var(--bc)/0.5))">No artists found</div>
|
||||||
{#else}
|
{#else}
|
||||||
{#for artist in artists}
|
{#for artist in artists}
|
||||||
<div class="flex items-center justify-between p-3 hover:bg-base-200 border-b border-base-300 last:border-b-0 transition-colors">
|
<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-1 min-w-0 mr-2">
|
onmouseover="this.style.background='var(--fallback-b2,oklch(var(--b2)/1))'" onmouseout="this.style.background=''">
|
||||||
<div class="flex items-center gap-2">
|
<div style="flex:1;min-width:0;margin-right:0.5rem">
|
||||||
<span class="font-semibold text-sm">{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}
|
{#if artist.type}
|
||||||
<span class="badge badge-ghost badge-xs">{artist.type}</span>
|
<span class="badge badge-ghost badge-xs">{artist.type}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if artist.disambiguation}
|
{#if artist.disambiguation}
|
||||||
<div class="text-xs text-base-content/50 truncate">{artist.disambiguation}</div>
|
<div style="font-size:0.75rem;color:var(--fallback-bc,oklch(var(--bc)/0.5));overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{artist.disambiguation}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if artist.area}
|
{#if artist.area}
|
||||||
<div class="text-xs text-base-content/40">{artist.area}</div>
|
<div style="font-size:0.75rem;color:var(--fallback-bc,oklch(var(--bc)/0.4))">{artist.area}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
{#if artist.alreadyTracked}
|
{#if artist.alreadyTracked}
|
||||||
<span class="badge badge-success badge-sm">✓ Added</span>
|
<button class="btn btn-warning btn-sm"
|
||||||
|
hx-delete="/api/artists/{artist.mbid}/from-search"
|
||||||
|
hx-include="#search-input"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-swap="innerHTML">✓ Added</button>
|
||||||
{#else}
|
{#else}
|
||||||
<button class="btn btn-primary btn-sm"
|
<button class="btn btn-primary btn-sm"
|
||||||
hx-post="/api/artists/{artist.mbid}/track"
|
hx-post="/api/artists/{artist.mbid}/track"
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/htmx.org@2/dist/htmx.min.js"></script>
|
<script src="https://unpkg.com/htmx.org@2/dist/htmx.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
body {
|
||||||
|
width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
#search-wrapper { position: relative; width: 100%; }
|
#search-wrapper { position: relative; width: 100%; }
|
||||||
#search-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; }
|
#search-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; }
|
||||||
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem; }
|
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem; }
|
||||||
@@ -21,6 +25,13 @@
|
|||||||
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;
|
||||||
@@ -34,18 +45,18 @@
|
|||||||
<div class="navbar-start"></div>
|
<div class="navbar-start"></div>
|
||||||
|
|
||||||
<div class="navbar-center gap-4">
|
<div class="navbar-center gap-4">
|
||||||
<a href="/" class="font-bold text-lg text-base-content no-underline">DiscDrop</a>
|
<a href="/" class="btn btn-ghost btn-sm no-underline">DiscDrop</a>
|
||||||
<div id="search-wrapper" style="width:18rem">
|
<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 artists..."
|
<input id="search-input" type="text" placeholder="Search artist to follow"
|
||||||
class="input input-bordered input-sm w-full" name="q" autocomplete="off">
|
class="input input-bordered input-sm" name="q" autocomplete="off" style="width:100%">
|
||||||
</form>
|
</form>
|
||||||
<div id="search-results"></div>
|
<div id="search-results"></div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/artists" class="btn btn-ghost btn-sm {#if activeTab == 'artists'}btn-active{/if}">Artists</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-end gap-2">
|
<div class="navbar-end gap-2">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load">
|
<div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load" style="display:flex;flex-direction:column;gap:2.5rem">
|
||||||
<div style="display:flex;justify-content:center;padding:4rem 0">
|
<div style="display:flex;justify-content:center;padding:4rem 0">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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("&") || xml.contains("&amp;"));
|
||||||
|
assertTrue(xml.contains("<") || xml.contains("&lt;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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)
|
||||||
Reference in New Issue
Block a user