Phase 1: Project scaffold
- Quarkus Maven project with all dependencies - SQLite DatabaseService with DAO methods - MusicBrainz REST client with DTOs - ArtistService, ReleaseGroupService, CacheService, FeedService - HTMX web controllers (ReleasesPage, ArtistsPage, ArtistController, FeedController, SettingsController) - RSS feed builder using Rome library - Qute templates with daisyUI theme switching - Base layout with navbar, search, theme picker, settings modal
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
target/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.discdrop</groupId>
|
||||||
|
<artifactId>discdrop</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<quarkus.platform.version>3.37.1</quarkus.platform.version>
|
||||||
|
<compiler-plugin.version>3.13.0</compiler-plugin.version>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<surefire-plugin.version>3.5.2</surefire-plugin.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-bom</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- REST server (resteasy-reactive successor) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-resteasy</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Qute templating -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-resteasy-qute</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- REST Client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-client</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- CDI -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-arc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- SQLite JDBC -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
|
<version>3.49.1.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- RSS generation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.rometools</groupId>
|
||||||
|
<artifactId>rome</artifactId>
|
||||||
|
<version>2.1.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson for JSON parsing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<version>5.5.1</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
<goal>generate-code</goal>
|
||||||
|
<goal>generate-code-tests</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>${compiler-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<release>21</release>
|
||||||
|
<parameters>true</parameters>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${surefire-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||||
|
<maven.home>${maven.home}</maven.home>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.discdrop.client;
|
||||||
|
|
||||||
|
import com.discdrop.client.dto.ArtistDetailsResponse;
|
||||||
|
import com.discdrop.client.dto.ArtistSearchResponse;
|
||||||
|
import com.discdrop.client.dto.ReleaseGroupBrowseResponse;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||||
|
|
||||||
|
@Path("/")
|
||||||
|
@RegisterRestClient(configKey = "musicbrainz")
|
||||||
|
public interface MusicBrainzClient {
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("artist/")
|
||||||
|
ArtistSearchResponse searchArtists(
|
||||||
|
@QueryParam("query") String query,
|
||||||
|
@QueryParam("limit") int limit,
|
||||||
|
@QueryParam("fmt") String fmt
|
||||||
|
);
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("artist/{id}")
|
||||||
|
ArtistDetailsResponse getArtistDetails(
|
||||||
|
@PathParam("id") String mbid,
|
||||||
|
@QueryParam("inc") String inc,
|
||||||
|
@QueryParam("fmt") String fmt
|
||||||
|
);
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("release-group")
|
||||||
|
ReleaseGroupBrowseResponse browseReleaseGroups(
|
||||||
|
@QueryParam("artist") String artistMbid,
|
||||||
|
@QueryParam("limit") int limit,
|
||||||
|
@QueryParam("offset") int offset,
|
||||||
|
@QueryParam("inc") String inc,
|
||||||
|
@QueryParam("fmt") String fmt
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.discdrop.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ArtistDetailsResponse(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String disambiguation,
|
||||||
|
Area area,
|
||||||
|
String type,
|
||||||
|
String country,
|
||||||
|
List<Relation> relations
|
||||||
|
) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Area(String name) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Relation(String type, Url url) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Url(String resource) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.discdrop.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ArtistSearchResponse(
|
||||||
|
List<Artist> artists,
|
||||||
|
int count
|
||||||
|
) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Artist(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String disambiguation,
|
||||||
|
Area area,
|
||||||
|
String type,
|
||||||
|
String country
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Area(String name) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.discdrop.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ReleaseGroupBrowseResponse(
|
||||||
|
List<ReleaseGroup> releaseGroups,
|
||||||
|
int releaseGroupCount
|
||||||
|
) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ReleaseGroup(
|
||||||
|
String id,
|
||||||
|
String title,
|
||||||
|
String firstReleaseDate,
|
||||||
|
PrimaryType primaryType,
|
||||||
|
List<SecondaryType> secondaryTypes,
|
||||||
|
List<ArtistCredit> artistCredit
|
||||||
|
) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record PrimaryType(String id, String name) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record SecondaryType(String id, String name) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ArtistCredit(Artist artist) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Artist(String id, String name) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package com.discdrop.db;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.sqlite.SQLiteDataSource;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class DatabaseService {
|
||||||
|
|
||||||
|
private SQLiteDataSource ds;
|
||||||
|
|
||||||
|
@ConfigProperty(name = "discdrop.db.path")
|
||||||
|
String dbPath;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() throws IOException {
|
||||||
|
Path dbFile = Path.of(dbPath);
|
||||||
|
Path parent = dbFile.getParent();
|
||||||
|
if (parent != null && !Files.exists(parent)) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
ds = new SQLiteDataSource();
|
||||||
|
ds.setUrl("jdbc:sqlite:" + dbPath);
|
||||||
|
ds.setEnforceForeignKeys(true);
|
||||||
|
|
||||||
|
try (Connection conn = ds.getConnection(); Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tracked_artist (
|
||||||
|
mbid TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
disambiguation TEXT NOT NULL DEFAULT '',
|
||||||
|
area_name TEXT NOT NULL DEFAULT '',
|
||||||
|
album_monitored INTEGER NOT NULL DEFAULT 1,
|
||||||
|
single_monitored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ep_monitored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
broadcast_monitored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
other_monitored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
stmt.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS release_group_cache (
|
||||||
|
mbid TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
primary_type TEXT NOT NULL,
|
||||||
|
secondary_types TEXT NOT NULL DEFAULT '[]',
|
||||||
|
first_release_date TEXT,
|
||||||
|
artist_mbid TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
cached_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_rgc_artist ON release_group_cache(artist_mbid)");
|
||||||
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_rgc_date ON release_group_cache(first_release_date)");
|
||||||
|
stmt.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
stmt.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('cache_ttl_hours', '8')");
|
||||||
|
stmt.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('default_types', 'Album')");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("Failed to initialize database", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connection getConnection() throws SQLException {
|
||||||
|
return ds.getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tracked_artist DAO ---
|
||||||
|
|
||||||
|
public List<Record.TrackedArtist> findAllArtists() {
|
||||||
|
var artists = new ArrayList<Record.TrackedArtist>();
|
||||||
|
var sql = "SELECT * FROM tracked_artist ORDER BY name";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery(sql)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
artists.add(mapArtist(rs));
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Record.TrackedArtist> findByMbid(String mbid) {
|
||||||
|
var sql = "SELECT * FROM tracked_artist WHERE mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, mbid);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return Optional.of(mapArtist(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean exists(String mbid) {
|
||||||
|
var sql = "SELECT 1 FROM tracked_artist WHERE mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, mbid);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertArtist(Record.TrackedArtist a) {
|
||||||
|
var sql = """
|
||||||
|
INSERT INTO tracked_artist
|
||||||
|
(mbid, name, disambiguation, area_name,
|
||||||
|
album_monitored, single_monitored, ep_monitored,
|
||||||
|
broadcast_monitored, other_monitored)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, a.mbid());
|
||||||
|
ps.setString(2, a.name());
|
||||||
|
ps.setString(3, a.disambiguation());
|
||||||
|
ps.setString(4, a.areaName());
|
||||||
|
ps.setInt(5, a.albumMonitored() ? 1 : 0);
|
||||||
|
ps.setInt(6, a.singleMonitored() ? 1 : 0);
|
||||||
|
ps.setInt(7, a.epMonitored() ? 1 : 0);
|
||||||
|
ps.setInt(8, a.broadcastMonitored() ? 1 : 0);
|
||||||
|
ps.setInt(9, a.otherMonitored() ? 1 : 0);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateMonitoring(String mbid, Record.MonitoringFlags flags) {
|
||||||
|
var sql = """
|
||||||
|
UPDATE tracked_artist SET
|
||||||
|
album_monitored = ?, single_monitored = ?, ep_monitored = ?,
|
||||||
|
broadcast_monitored = ?, other_monitored = ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE mbid = ?
|
||||||
|
""";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setInt(1, flags.album() ? 1 : 0);
|
||||||
|
ps.setInt(2, flags.single() ? 1 : 0);
|
||||||
|
ps.setInt(3, flags.ep() ? 1 : 0);
|
||||||
|
ps.setInt(4, flags.broadcast() ? 1 : 0);
|
||||||
|
ps.setInt(5, flags.other() ? 1 : 0);
|
||||||
|
ps.setString(6, mbid);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteArtist(String mbid) {
|
||||||
|
var sql = "DELETE FROM tracked_artist WHERE mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, mbid);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- release_group_cache DAO ---
|
||||||
|
|
||||||
|
public void upsertReleaseGroups(List<Record.ReleaseGroup> groups) {
|
||||||
|
var sql = """
|
||||||
|
INSERT OR REPLACE INTO release_group_cache
|
||||||
|
(mbid, title, primary_type, secondary_types,
|
||||||
|
first_release_date, artist_mbid, artist_name, cached_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
""";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
for (var g : groups) {
|
||||||
|
ps.setString(1, g.mbid());
|
||||||
|
ps.setString(2, g.title());
|
||||||
|
ps.setString(3, g.primaryType());
|
||||||
|
ps.setString(4, g.secondaryTypes());
|
||||||
|
ps.setString(5, g.firstReleaseDate());
|
||||||
|
ps.setString(6, g.artistMbid());
|
||||||
|
ps.setString(7, g.artistName());
|
||||||
|
ps.addBatch();
|
||||||
|
}
|
||||||
|
ps.executeBatch();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Record.ReleaseGroup> getFeed(List<String> artistMbids, List<String> primaryTypes) {
|
||||||
|
var groups = new ArrayList<Record.ReleaseGroup>();
|
||||||
|
if (artistMbids.isEmpty()) return groups;
|
||||||
|
|
||||||
|
var placeholders = String.join(",", artistMbids.stream().map(m -> "?").toList());
|
||||||
|
var sql = "SELECT * FROM release_group_cache WHERE artist_mbid IN (" + placeholders + ")";
|
||||||
|
if (!primaryTypes.isEmpty()) {
|
||||||
|
var typePlaceholders = String.join(",", primaryTypes.stream().map(t -> "?").toList());
|
||||||
|
sql += " AND primary_type IN (" + typePlaceholders + ")";
|
||||||
|
}
|
||||||
|
sql += " ORDER BY first_release_date ASC";
|
||||||
|
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
int idx = 1;
|
||||||
|
for (var mbid : artistMbids) ps.setString(idx++, mbid);
|
||||||
|
for (var type : primaryTypes) ps.setString(idx++, type);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
groups.add(mapReleaseGroup(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteReleaseGroupsForArtist(String mbid) {
|
||||||
|
var sql = "DELETE FROM release_group_cache WHERE artist_mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, mbid);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countReleaseGroupsForArtist(String mbid) {
|
||||||
|
var sql = "SELECT COUNT(*) FROM release_group_cache WHERE artist_mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, mbid);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) return rs.getLong(1);
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Instant> getLastCachedAt(String artistMbid) {
|
||||||
|
var sql = "SELECT MAX(cached_at) FROM release_group_cache WHERE artist_mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, artistMbid);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next() && rs.getString(1) != null) {
|
||||||
|
return Optional.of(Instant.parse(rs.getString(1).replace(" ", "T") + "Z"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- settings DAO ---
|
||||||
|
|
||||||
|
public String getSetting(String key, String defaultValue) {
|
||||||
|
var sql = "SELECT value FROM settings WHERE key = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, key);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) return rs.getString("value");
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSetting(String key, String value) {
|
||||||
|
var sql = "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, key);
|
||||||
|
ps.setString(2, value);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mappers ---
|
||||||
|
|
||||||
|
private Record.TrackedArtist mapArtist(ResultSet rs) throws SQLException {
|
||||||
|
return new Record.TrackedArtist(
|
||||||
|
rs.getString("mbid"),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("disambiguation"),
|
||||||
|
rs.getString("area_name"),
|
||||||
|
rs.getInt("album_monitored") == 1,
|
||||||
|
rs.getInt("single_monitored") == 1,
|
||||||
|
rs.getInt("ep_monitored") == 1,
|
||||||
|
rs.getInt("broadcast_monitored") == 1,
|
||||||
|
rs.getInt("other_monitored") == 1,
|
||||||
|
parseInstant(rs.getString("created_at")),
|
||||||
|
parseInstant(rs.getString("updated_at"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Record.ReleaseGroup mapReleaseGroup(ResultSet rs) throws SQLException {
|
||||||
|
return new Record.ReleaseGroup(
|
||||||
|
rs.getString("mbid"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("primary_type"),
|
||||||
|
rs.getString("secondary_types"),
|
||||||
|
rs.getString("first_release_date"),
|
||||||
|
rs.getString("artist_mbid"),
|
||||||
|
rs.getString("artist_name"),
|
||||||
|
parseInstant(rs.getString("cached_at"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant parseInstant(String ts) {
|
||||||
|
if (ts == null || ts.isBlank()) return null;
|
||||||
|
return Instant.parse(ts.replace(" ", "T") + "Z");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.discdrop.db;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public final class Record {
|
||||||
|
|
||||||
|
private Record() {}
|
||||||
|
|
||||||
|
public record TrackedArtist(
|
||||||
|
String mbid,
|
||||||
|
String name,
|
||||||
|
String disambiguation,
|
||||||
|
String areaName,
|
||||||
|
boolean albumMonitored,
|
||||||
|
boolean singleMonitored,
|
||||||
|
boolean epMonitored,
|
||||||
|
boolean broadcastMonitored,
|
||||||
|
boolean otherMonitored,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record MonitoringFlags(
|
||||||
|
boolean album,
|
||||||
|
boolean single,
|
||||||
|
boolean ep,
|
||||||
|
boolean broadcast,
|
||||||
|
boolean other
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record ReleaseGroup(
|
||||||
|
String mbid,
|
||||||
|
String title,
|
||||||
|
String primaryType,
|
||||||
|
String secondaryTypes,
|
||||||
|
String firstReleaseDate,
|
||||||
|
String artistMbid,
|
||||||
|
String artistName,
|
||||||
|
Instant cachedAt
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.discdrop.service;
|
||||||
|
|
||||||
|
import com.discdrop.client.MusicBrainzClient;
|
||||||
|
import com.discdrop.client.dto.ArtistDetailsResponse;
|
||||||
|
import com.discdrop.client.dto.ArtistSearchResponse;
|
||||||
|
import com.discdrop.db.DatabaseService;
|
||||||
|
import com.discdrop.db.Record.MonitoringFlags;
|
||||||
|
import com.discdrop.db.Record.TrackedArtist;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ArtistService {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MusicBrainzClient mbClient;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DatabaseService db;
|
||||||
|
|
||||||
|
public record SearchResult(String mbid, String name, String disambiguation,
|
||||||
|
String area, String type, String country,
|
||||||
|
boolean alreadyTracked) {}
|
||||||
|
|
||||||
|
public List<SearchResult> search(String query) {
|
||||||
|
var resp = mbClient.searchArtists(
|
||||||
|
"artist:" + query, 10, "json");
|
||||||
|
if (resp == null || resp.artists() == null) return List.of();
|
||||||
|
|
||||||
|
var results = new ArrayList<SearchResult>();
|
||||||
|
for (var a : resp.artists()) {
|
||||||
|
results.add(new SearchResult(
|
||||||
|
a.id(),
|
||||||
|
a.name(),
|
||||||
|
a.disambiguation() != null ? a.disambiguation() : "",
|
||||||
|
a.area() != null ? a.area().name() : (a.country() != null ? a.country() : ""),
|
||||||
|
a.type() != null ? a.type() : "",
|
||||||
|
a.country() != null ? a.country() : "",
|
||||||
|
db.exists(a.id())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<TrackedArtist> findByMbid(String mbid) {
|
||||||
|
return db.findByMbid(mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TrackedArtist> findAll() {
|
||||||
|
return db.findAllArtists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackedArtist addArtist(String mbid) {
|
||||||
|
ArtistDetailsResponse details = mbClient.getArtistDetails(mbid, "url-rels", "json");
|
||||||
|
if (details == null) {
|
||||||
|
throw new RuntimeException("Artist not found: " + mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultTypes = db.getSetting("default_types", "Album");
|
||||||
|
var flags = parseDefaultTypes(defaultTypes);
|
||||||
|
|
||||||
|
var artist = new TrackedArtist(
|
||||||
|
details.id(),
|
||||||
|
details.name(),
|
||||||
|
details.disambiguation() != null ? details.disambiguation() : "",
|
||||||
|
details.area() != null ? details.area().name() : "",
|
||||||
|
flags.album(),
|
||||||
|
flags.single(),
|
||||||
|
flags.ep(),
|
||||||
|
flags.broadcast(),
|
||||||
|
flags.other(),
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
db.insertArtist(artist);
|
||||||
|
return artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeArtist(String mbid) {
|
||||||
|
db.deleteReleaseGroupsForArtist(mbid);
|
||||||
|
db.deleteArtist(mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateMonitoring(String mbid, MonitoringFlags flags) {
|
||||||
|
db.updateMonitoring(mbid, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MonitoringFlags parseDefaultTypes(String types) {
|
||||||
|
boolean album = false, single = false, ep = false,
|
||||||
|
broadcast = false, other = false;
|
||||||
|
if (types == null || types.isBlank()) {
|
||||||
|
album = true;
|
||||||
|
return new MonitoringFlags(album, single, ep, broadcast, other);
|
||||||
|
}
|
||||||
|
for (var t : types.split(",")) {
|
||||||
|
switch (t.trim().toLowerCase()) {
|
||||||
|
case "album" -> album = true;
|
||||||
|
case "single" -> single = true;
|
||||||
|
case "ep" -> ep = true;
|
||||||
|
case "broadcast" -> broadcast = true;
|
||||||
|
case "other" -> other = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new MonitoringFlags(album, single, ep, broadcast, other);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.discdrop.service;
|
||||||
|
|
||||||
|
import com.discdrop.db.DatabaseService;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class CacheService {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DatabaseService db;
|
||||||
|
|
||||||
|
public long getTtlHours() {
|
||||||
|
var ttl = db.getSetting("cache_ttl_hours", "8");
|
||||||
|
try {
|
||||||
|
return Long.parseLong(ttl);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isStale(String artistMbid) {
|
||||||
|
Optional<Instant> lastCached = db.getLastCachedAt(artistMbid);
|
||||||
|
if (lastCached.isEmpty()) return true;
|
||||||
|
|
||||||
|
long ttlHours = getTtlHours();
|
||||||
|
return lastCached.get().plus(ttlHours, ChronoUnit.HOURS).isBefore(Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean needsRefresh(String artistMbid) {
|
||||||
|
return db.countReleaseGroupsForArtist(artistMbid) == 0 || isStale(artistMbid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.discdrop.service;
|
||||||
|
|
||||||
|
import com.discdrop.db.DatabaseService;
|
||||||
|
import com.discdrop.db.Record.ReleaseGroup;
|
||||||
|
import com.discdrop.db.Record.TrackedArtist;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class FeedService {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ArtistService artistService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ReleaseGroupService releaseGroupService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
CacheService cacheService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DatabaseService db;
|
||||||
|
|
||||||
|
public List<ReleaseGroup> getFeedEntries() {
|
||||||
|
var artists = artistService.findAll();
|
||||||
|
if (artists.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
var artistMbids = new ArrayList<String>();
|
||||||
|
for (var a : artists) {
|
||||||
|
if (cacheService.needsRefresh(a.mbid())) {
|
||||||
|
var types = getMonitoredTypes(a);
|
||||||
|
releaseGroupService.fetchAndCache(a.mbid(), a.name(), types);
|
||||||
|
}
|
||||||
|
artistMbids.add(a.mbid());
|
||||||
|
}
|
||||||
|
|
||||||
|
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
|
||||||
|
var monitoredTypes = new ArrayList<String>();
|
||||||
|
for (var a : artists) {
|
||||||
|
monitoredTypes.addAll(getMonitoredTypes(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.getFeed(artistMbids, monitoredTypes.isEmpty() ? allTypes : monitoredTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> getMonitoredTypes(TrackedArtist a) {
|
||||||
|
var types = new ArrayList<String>();
|
||||||
|
if (a.albumMonitored()) types.add("Album");
|
||||||
|
if (a.singleMonitored()) types.add("Single");
|
||||||
|
if (a.epMonitored()) types.add("EP");
|
||||||
|
if (a.broadcastMonitored()) types.add("Broadcast");
|
||||||
|
if (a.otherMonitored()) types.add("Other");
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.discdrop.service;
|
||||||
|
|
||||||
|
import com.discdrop.client.MusicBrainzClient;
|
||||||
|
import com.discdrop.client.dto.ReleaseGroupBrowseResponse;
|
||||||
|
import com.discdrop.db.DatabaseService;
|
||||||
|
import com.discdrop.db.Record.ReleaseGroup;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ReleaseGroupService {
|
||||||
|
|
||||||
|
private static final int MAX_RELEASE_GROUPS = 500;
|
||||||
|
private static final int PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MusicBrainzClient mbClient;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DatabaseService db;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ObjectMapper json;
|
||||||
|
|
||||||
|
public List<ReleaseGroup> fetchAndCache(String artistMbid, String artistName,
|
||||||
|
List<String> monitoredTypes) {
|
||||||
|
var allGroups = new ArrayList<ReleaseGroup>();
|
||||||
|
int offset = 0;
|
||||||
|
int total = Integer.MAX_VALUE;
|
||||||
|
|
||||||
|
while (offset < total && offset < MAX_RELEASE_GROUPS) {
|
||||||
|
ReleaseGroupBrowseResponse page = mbClient.browseReleaseGroups(
|
||||||
|
artistMbid, PAGE_SIZE, offset, "url-rels+artist-credits", "json");
|
||||||
|
|
||||||
|
if (page == null || page.releaseGroups() == null) break;
|
||||||
|
|
||||||
|
total = Math.min(page.releaseGroupCount(), MAX_RELEASE_GROUPS);
|
||||||
|
|
||||||
|
for (var rg : page.releaseGroups()) {
|
||||||
|
String primaryType = rg.primaryType() != null ? rg.primaryType().name() : "Other";
|
||||||
|
|
||||||
|
if (!monitoredTypes.contains(primaryType)) continue;
|
||||||
|
|
||||||
|
String secondaryTypes = "[]";
|
||||||
|
if (rg.secondaryTypes() != null && !rg.secondaryTypes().isEmpty()) {
|
||||||
|
try {
|
||||||
|
secondaryTypes = json.writeValueAsString(
|
||||||
|
rg.secondaryTypes().stream().map(s -> s.name()).toList());
|
||||||
|
} catch (Exception e) {
|
||||||
|
secondaryTypes = "[]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String rgArtistName = artistName;
|
||||||
|
if (rg.artistCredit() != null && !rg.artistCredit().isEmpty()) {
|
||||||
|
rgArtistName = rg.artistCredit().get(0).artist().name();
|
||||||
|
}
|
||||||
|
|
||||||
|
allGroups.add(new ReleaseGroup(
|
||||||
|
rg.id(),
|
||||||
|
rg.title(),
|
||||||
|
primaryType,
|
||||||
|
secondaryTypes,
|
||||||
|
rg.firstReleaseDate(),
|
||||||
|
artistMbid,
|
||||||
|
rgArtistName,
|
||||||
|
Instant.now()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += PAGE_SIZE;
|
||||||
|
|
||||||
|
if (offset < total) {
|
||||||
|
try { Thread.sleep(1100); } catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allGroups.isEmpty()) {
|
||||||
|
db.upsertReleaseGroups(allGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allGroups;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.discdrop.util;
|
||||||
|
|
||||||
|
import com.discdrop.db.Record.ReleaseGroup;
|
||||||
|
import com.rometools.rome.feed.synd.*;
|
||||||
|
import com.rometools.rome.io.SyndFeedOutput;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class RSSFeedBuilder {
|
||||||
|
|
||||||
|
@ConfigProperty(name = "discdrop.base-url")
|
||||||
|
String baseUrl;
|
||||||
|
|
||||||
|
public String buildRss(List<ReleaseGroup> entries) {
|
||||||
|
var feed = new SyndFeedImpl();
|
||||||
|
feed.setFeedType("rss_2.0");
|
||||||
|
feed.setTitle("DiscDrop — Release Feed");
|
||||||
|
feed.setLink(baseUrl);
|
||||||
|
feed.setDescription("Latest music release groups from your tracked artists");
|
||||||
|
|
||||||
|
var selfLink = new SyndLinkImpl();
|
||||||
|
selfLink.setHref(baseUrl + "/feed/rss.xml");
|
||||||
|
selfLink.setRel("self");
|
||||||
|
selfLink.setType("application/rss+xml");
|
||||||
|
feed.setLinks(List.of(selfLink));
|
||||||
|
|
||||||
|
var items = new ArrayList<SyndEntry>();
|
||||||
|
for (var rg : entries) {
|
||||||
|
var item = new SyndEntryImpl();
|
||||||
|
item.setTitle(rg.artistName() + " — " + rg.title());
|
||||||
|
item.setLink("https://musicbrainz.org/release-group/" + rg.mbid());
|
||||||
|
|
||||||
|
var guid = new SyndLinkImpl();
|
||||||
|
item.setUri("mbz-rg-" + rg.mbid());
|
||||||
|
|
||||||
|
var desc = new SyndContentImpl();
|
||||||
|
desc.setType("text/html");
|
||||||
|
desc.setValue(buildDescription(rg));
|
||||||
|
item.setDescription(desc);
|
||||||
|
|
||||||
|
item.setPublishedDate(parseDate(rg.firstReleaseDate()));
|
||||||
|
|
||||||
|
var category = new SyndCategoryImpl();
|
||||||
|
category.setName(rg.primaryType());
|
||||||
|
item.setCategories(List.of(category));
|
||||||
|
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
feed.setEntries(items);
|
||||||
|
feed.setPublishedDate(new Date());
|
||||||
|
|
||||||
|
try {
|
||||||
|
var output = new SyndFeedOutput();
|
||||||
|
return output.outputString(feed);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to generate RSS feed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildOpml() {
|
||||||
|
return """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<opml version="2.0">
|
||||||
|
<head>
|
||||||
|
<title>DiscDrop — Release Feed</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<outline text="DiscDrop Releases" type="rss"
|
||||||
|
xmlUrl="%s/feed/rss.xml" htmlUrl="%s"/>
|
||||||
|
</body>
|
||||||
|
</opml>
|
||||||
|
""".formatted(baseUrl, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDescription(ReleaseGroup rg) {
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.append("<img src=\"https://coverartarchive.org/release-group/")
|
||||||
|
.append(rg.mbid()).append("/front\" alt=\"Cover\" onerror=\"this.style.display='none'\">");
|
||||||
|
sb.append("<p><b>").append(escHtml(rg.artistName())).append("</b>")
|
||||||
|
.append(" <i>").append(escHtml(rg.title())).append("</i><br>");
|
||||||
|
sb.append("Type: ").append(escHtml(rg.primaryType()));
|
||||||
|
if (rg.secondaryTypes() != null && !rg.secondaryTypes().equals("[]")) {
|
||||||
|
sb.append(" | Secondary: ").append(escHtml(rg.secondaryTypes()));
|
||||||
|
}
|
||||||
|
sb.append("<br>First Release Date: ").append(rg.firstReleaseDate());
|
||||||
|
sb.append("</p>");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date parseDate(String dateStr) {
|
||||||
|
if (dateStr == null || dateStr.isBlank()) return new Date();
|
||||||
|
try {
|
||||||
|
ZonedDateTime zdt;
|
||||||
|
if (dateStr.length() == 4) {
|
||||||
|
zdt = ZonedDateTime.of(
|
||||||
|
Integer.parseInt(dateStr), 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"));
|
||||||
|
} else if (dateStr.length() == 7) {
|
||||||
|
var parts = dateStr.split("-");
|
||||||
|
zdt = ZonedDateTime.of(
|
||||||
|
Integer.parseInt(parts[0]), Integer.parseInt(parts[1]),
|
||||||
|
1, 0, 0, 0, 0, ZoneId.of("UTC"));
|
||||||
|
} else {
|
||||||
|
zdt = ZonedDateTime.parse(dateStr + "T00:00:00Z");
|
||||||
|
}
|
||||||
|
return Date.from(zdt.toInstant());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escHtml(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import com.discdrop.db.Record.MonitoringFlags;
|
||||||
|
import com.discdrop.db.Record.ReleaseGroup;
|
||||||
|
import com.discdrop.db.Record.TrackedArtist;
|
||||||
|
import com.discdrop.service.ArtistService;
|
||||||
|
import com.discdrop.service.ArtistService.SearchResult;
|
||||||
|
import com.discdrop.service.CacheService;
|
||||||
|
import com.discdrop.service.FeedService;
|
||||||
|
import com.discdrop.service.ReleaseGroupService;
|
||||||
|
import io.quarkus.qute.Template;
|
||||||
|
import io.quarkus.qute.TemplateInstance;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.*;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import org.jboss.resteasy.reactive.RestForm;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Path("/api/artists")
|
||||||
|
public class ArtistController {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ArtistService artistService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ReleaseGroupService releaseGroupService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
CacheService cacheService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
FeedService feedService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Template fragments$search_results;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Template fragments$artist_list;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Template fragments$feed_entries;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/search")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance search(@QueryParam("q") String q) {
|
||||||
|
List<SearchResult> results;
|
||||||
|
if (q == null || q.isBlank()) {
|
||||||
|
results = List.of();
|
||||||
|
} else {
|
||||||
|
results = artistService.search(q);
|
||||||
|
}
|
||||||
|
return fragments$search_results.data("artists", results);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{mbid}/track")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance trackArtist(@PathParam("mbid") String mbid) {
|
||||||
|
var artist = artistService.addArtist(mbid);
|
||||||
|
|
||||||
|
var types = getMonitoredTypes(artist);
|
||||||
|
releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), types);
|
||||||
|
|
||||||
|
var allArtists = artistService.findAll();
|
||||||
|
return fragments$artist_list.data("artists", allArtists);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("/{mbid}")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance removeArtist(@PathParam("mbid") String mbid) {
|
||||||
|
artistService.removeArtist(mbid);
|
||||||
|
var allArtists = artistService.findAll();
|
||||||
|
return fragments$artist_list.data("artists", allArtists);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PATCH
|
||||||
|
@Path("/{mbid}/monitor")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance updateMonitoring(
|
||||||
|
@PathParam("mbid") String mbid,
|
||||||
|
@QueryParam("type") String type) {
|
||||||
|
var existing = artistService.findByMbid(mbid)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Artist not found"));
|
||||||
|
var flags = new MonitoringFlags(
|
||||||
|
type.equals("album") ? !existing.albumMonitored() : existing.albumMonitored(),
|
||||||
|
type.equals("single") ? !existing.singleMonitored() : existing.singleMonitored(),
|
||||||
|
type.equals("ep") ? !existing.epMonitored() : existing.epMonitored(),
|
||||||
|
type.equals("broadcast") ? !existing.broadcastMonitored() : existing.broadcastMonitored(),
|
||||||
|
type.equals("other") ? !existing.otherMonitored() : existing.otherMonitored()
|
||||||
|
);
|
||||||
|
artistService.updateMonitoring(mbid, flags);
|
||||||
|
var allArtists = artistService.findAll();
|
||||||
|
return fragments$artist_list.data("artists", allArtists);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/list")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance artistList() {
|
||||||
|
var artists = artistService.findAll();
|
||||||
|
return fragments$artist_list.data("artists", artists);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/feed-table")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance feedTable() {
|
||||||
|
var entries = feedService.getFeedEntries();
|
||||||
|
return fragments$feed_entries.data("entries", entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> getMonitoredTypes(TrackedArtist a) {
|
||||||
|
var types = new ArrayList<String>();
|
||||||
|
if (a.albumMonitored()) types.add("Album");
|
||||||
|
if (a.singleMonitored()) types.add("Single");
|
||||||
|
if (a.epMonitored()) types.add("EP");
|
||||||
|
if (a.broadcastMonitored()) types.add("Broadcast");
|
||||||
|
if (a.otherMonitored()) types.add("Other");
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import io.quarkus.qute.Template;
|
||||||
|
import io.quarkus.qute.TemplateInstance;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
@Path("/artists")
|
||||||
|
public class ArtistsPage {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Template artists;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance index() {
|
||||||
|
return artists.data("activeTab", "artists");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import com.discdrop.service.FeedService;
|
||||||
|
import com.discdrop.util.RSSFeedBuilder;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
@Path("/feed")
|
||||||
|
public class FeedController {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
FeedService feedService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
RSSFeedBuilder feedBuilder;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/rss.xml")
|
||||||
|
@Produces(MediaType.APPLICATION_XML)
|
||||||
|
public Response rssFeed() {
|
||||||
|
var entries = feedService.getFeedEntries();
|
||||||
|
var xml = feedBuilder.buildRss(entries);
|
||||||
|
return Response.ok(xml, MediaType.APPLICATION_XML).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/opml")
|
||||||
|
@Produces(MediaType.APPLICATION_XML)
|
||||||
|
public Response opml() {
|
||||||
|
var xml = feedBuilder.buildOpml();
|
||||||
|
return Response.ok(xml, MediaType.APPLICATION_XML).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import io.quarkus.qute.Template;
|
||||||
|
import io.quarkus.qute.TemplateInstance;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
@Path("/")
|
||||||
|
public class ReleasesPage {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Template releases;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance index() {
|
||||||
|
return releases.data("activeTab", "releases");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import com.discdrop.db.DatabaseService;
|
||||||
|
import io.quarkus.qute.Template;
|
||||||
|
import io.quarkus.qute.TemplateInstance;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.*;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
@Path("/api/settings")
|
||||||
|
public class SettingsController {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DatabaseService db;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Template fragments$settings_form;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/form")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance settingsForm() {
|
||||||
|
var cacheTtl = db.getSetting("cache_ttl_hours", "8");
|
||||||
|
var defaultTypes = db.getSetting("default_types", "Album");
|
||||||
|
return fragments$settings_form.data("cacheTtl", cacheTtl)
|
||||||
|
.data("defaultTypes", defaultTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PATCH
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public Response saveSettings(
|
||||||
|
@QueryParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl,
|
||||||
|
@QueryParam("default_types") @DefaultValue("Album") String defaultTypes) {
|
||||||
|
db.setSetting("cache_ttl_hours", cacheTtl);
|
||||||
|
db.setSetting("default_types", defaultTypes);
|
||||||
|
return Response.ok("<div class=\"alert alert-success\">Settings saved</div>")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Server
|
||||||
|
quarkus.http.port=8080
|
||||||
|
|
||||||
|
# MusicBrainz API
|
||||||
|
quarkus.rest-client."com.discdrop.client.MusicBrainzClient".url=https://musicbrainz.org/ws/2
|
||||||
|
quarkus.rest-client."com.discdrop.client.MusicBrainzClient".default-headers=User-Agent: DiscDrop/1.0 (david.alvarez.81@gmail.com)
|
||||||
|
|
||||||
|
# Qute templating
|
||||||
|
quarkus.qute.suffixes=html
|
||||||
|
quarkus.qute.content-types.html=text/html
|
||||||
|
|
||||||
|
# Jackson
|
||||||
|
quarkus.jackson.fail-on-unknown-properties=false
|
||||||
|
|
||||||
|
# DiscDrop settings
|
||||||
|
discdrop.db.path=${DISC_DROP_DB_PATH:data/discdrop.db}
|
||||||
|
discdrop.cache.ttl-hours=${DISC_DROP_CACHE_TTL:8}
|
||||||
|
discdrop.base-url=${DISC_DROP_BASE_URL:http://localhost:8080}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{#include layouts/base}
|
||||||
|
{#page-content}
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">🎤 Artists</h2>
|
||||||
|
<div id="artist-list" hx-get="/api/artists/list" hx-trigger="load">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/page-content}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{#if artists.isEmpty()}
|
||||||
|
<div class="card bg-base-100 shadow-xl p-8 text-center">
|
||||||
|
<p class="text-lg text-base-content/60">No artists tracked yet</p>
|
||||||
|
<p class="text-sm text-base-content/40 mt-2">Search for artists using the search bar above and click "+ Add"</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Since</th>
|
||||||
|
<th>Release Types</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#for artist in artists}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium">{artist.name}</div>
|
||||||
|
{#if artist.disambiguation}
|
||||||
|
<div class="text-xs text-base-content/60">{artist.disambiguation}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/60">{artist.createdAt}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<label class="label cursor-pointer gap-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary"
|
||||||
|
{#if artist.albumMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=album"
|
||||||
|
hx-target="#artist-list" hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs">Album</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs"
|
||||||
|
{#if artist.singleMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=single"
|
||||||
|
hx-target="#artist-list" hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs">Single</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs"
|
||||||
|
{#if artist.epMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=ep"
|
||||||
|
hx-target="#artist-list" hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs">EP</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs"
|
||||||
|
{#if artist.broadcastMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=broadcast"
|
||||||
|
hx-target="#artist-list" hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs">Broadcast</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs"
|
||||||
|
{#if artist.otherMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=other"
|
||||||
|
hx-target="#artist-list" hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs">Other</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-error btn-xs"
|
||||||
|
hx-delete="/api/artists/{artist.mbid}"
|
||||||
|
hx-target="#artist-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="return confirm('Remove {artist.name}?')">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/for}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{#if entries.isEmpty()}
|
||||||
|
<div class="card bg-base-100 shadow-xl p-8 text-center">
|
||||||
|
<p class="text-lg text-base-content/60">No releases found</p>
|
||||||
|
<p class="text-sm text-base-content/40 mt-2">Add artists to your collection to see their releases here</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Release</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#for entry in entries}
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{entry.artistName}</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://musicbrainz.org/release-group/{entry.mbid}"
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
class="link link-hover">
|
||||||
|
{entry.title}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-outline badge-sm">{entry.primaryType}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/60">{entry.firstReleaseDate}</td>
|
||||||
|
</tr>
|
||||||
|
{/for}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<div class="card bg-base-100 shadow-xl mt-1 max-h-64 overflow-y-auto">
|
||||||
|
{#if artists.isEmpty()}
|
||||||
|
<div class="p-3 text-sm text-base-content/60">No artists found</div>
|
||||||
|
{:else}
|
||||||
|
{#for artist in artists}
|
||||||
|
<div class="flex items-center justify-between p-3 hover:bg-base-200 border-b border-base-200 last:border-b-0">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="font-medium">{artist.name}</span>
|
||||||
|
{#if artist.disambiguation}
|
||||||
|
<span class="text-sm text-base-content/60">— {artist.disambiguation}</span>
|
||||||
|
{/if}
|
||||||
|
<br>
|
||||||
|
<span class="text-xs text-base-content/40">{artist.area}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0">
|
||||||
|
{#if artist.alreadyTracked}
|
||||||
|
<span class="badge badge-success badge-sm">✓ Added</span>
|
||||||
|
{:else}
|
||||||
|
<button class="btn btn-primary btn-xs"
|
||||||
|
hx-post="/api/artists/{artist.mbid}/track"
|
||||||
|
hx-target="#artist-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="this.closest('#search-results').innerHTML=''; document.querySelector('[name=q]').value=''">
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/for}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<h3 class="font-bold text-lg mb-4">⚙️ Settings</h3>
|
||||||
|
<form hx-patch="/api/settings"
|
||||||
|
hx-target="#settings-modal-content"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Refresh releases every</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="radio" name="cache_ttl_hours" value="6" class="radio radio-primary radio-sm"
|
||||||
|
{#if cacheTtl == '6'}checked{/if}>
|
||||||
|
<span class="label-text">6 hours</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="radio" name="cache_ttl_hours" value="8" class="radio radio-primary radio-sm"
|
||||||
|
{#if cacheTtl == '8'}checked{/if}>
|
||||||
|
<span class="label-text">8 hours</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="radio" name="cache_ttl_hours" value="12" class="radio radio-primary radio-sm"
|
||||||
|
{#if cacheTtl == '12'}checked{/if}>
|
||||||
|
<span class="label-text">12 hours</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="radio" name="cache_ttl_hours" value="24" class="radio radio-primary radio-sm"
|
||||||
|
{#if cacheTtl == '24'}checked{/if}>
|
||||||
|
<span class="label-text">24 hours</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Default Release Types (when adding artists)</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="Album" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('Album')}checked{/if}>
|
||||||
|
<span class="label-text">Album</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="Single" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('Single')}checked{/if}>
|
||||||
|
<span class="label-text">Single</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="EP" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('EP')}checked{/if}>
|
||||||
|
<span class="label-text">EP</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="Broadcast" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('Broadcast')}checked{/if}>
|
||||||
|
<span class="label-text">Broadcast</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="Other" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('Other')}checked{/if}>
|
||||||
|
<span class="label-text">Other</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn" onclick="settingsModal.close()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DiscDrop</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.23/dist/full.min.css" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.search-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-base-200">
|
||||||
|
<div class="navbar bg-base-100 shadow-lg px-4">
|
||||||
|
<div class="flex-1 gap-2">
|
||||||
|
<a href="/" class="text-xl font-bold">🎵 DiscDrop</a>
|
||||||
|
<div class="tabs tabs-box ml-4 hidden sm:flex">
|
||||||
|
<a href="/" class="tab {#if activeTab == 'releases'}tab-active{/if}">Releases</a>
|
||||||
|
<a href="/artists" class="tab {#if activeTab == 'artists'}tab-active{/if}">Artists</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-none gap-2 relative">
|
||||||
|
<div class="form-control">
|
||||||
|
<input type="text" placeholder="Search artists..."
|
||||||
|
class="input input-bordered input-sm w-48 md:w-64"
|
||||||
|
name="q"
|
||||||
|
hx-get="/api/artists/search"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div id="search-results" class="search-dropdown"></div>
|
||||||
|
|
||||||
|
<select class="select select-bordered select-sm w-full max-w-[8rem]" data-choose-theme>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="synthwave">Synthwave</option>
|
||||||
|
<option value="retro">Retro</option>
|
||||||
|
<option value="dracula">Dracula</option>
|
||||||
|
<option value="night">Night</option>
|
||||||
|
<option value="dim">Dim</option>
|
||||||
|
<option value="nord">Nord</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost btn-sm"
|
||||||
|
onclick="settingsModal.showModal()">⚙️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="container mx-auto p-4">
|
||||||
|
{#insert page-content}{/page-content}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="settingsModal" class="modal">
|
||||||
|
<div class="modal-box" id="settings-modal-content"
|
||||||
|
hx-get="/api/settings/form"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{#include layouts/base}
|
||||||
|
{#page-content}
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">📡 Releases</h2>
|
||||||
|
<div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex gap-4">
|
||||||
|
<a href="/feed/rss.xml" class="btn btn-primary">📥 RSS Feed</a>
|
||||||
|
<a href="/feed/opml" class="btn btn-ghost">📋 OPML</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/page-content}
|
||||||
Reference in New Issue
Block a user