From f0be591fafe8e5ab67f7f43881855056c65c05d5 Mon Sep 17 00:00:00 2001 From: droideparanoico Date: Thu, 2 Jul 2026 15:29:16 +0200 Subject: [PATCH] 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 --- .gitignore | 13 + pom.xml | 132 +++++++ .../discdrop/client/MusicBrainzClient.java | 41 +++ .../client/dto/ArtistDetailsResponse.java | 24 ++ .../client/dto/ArtistSearchResponse.java | 23 ++ .../dto/ReleaseGroupBrowseResponse.java | 32 ++ .../java/com/discdrop/db/DatabaseService.java | 346 ++++++++++++++++++ src/main/java/com/discdrop/db/Record.java | 41 +++ .../com/discdrop/service/ArtistService.java | 111 ++++++ .../com/discdrop/service/CacheService.java | 36 ++ .../com/discdrop/service/FeedService.java | 57 +++ .../discdrop/service/ReleaseGroupService.java | 91 +++++ .../com/discdrop/util/RSSFeedBuilder.java | 124 +++++++ .../com/discdrop/web/ArtistController.java | 125 +++++++ .../java/com/discdrop/web/ArtistsPage.java | 22 ++ .../java/com/discdrop/web/FeedController.java | 37 ++ .../java/com/discdrop/web/ReleasesPage.java | 22 ++ .../com/discdrop/web/SettingsController.java | 40 ++ src/main/resources/application.properties | 18 + src/main/resources/templates/artists.html | 9 + .../templates/fragments/artist-list.html | 80 ++++ .../templates/fragments/feed-entries.html | 37 ++ .../templates/fragments/search-results.html | 31 ++ .../templates/fragments/settings-form.html | 70 ++++ .../resources/templates/layouts/base.html | 71 ++++ src/main/resources/templates/releases.html | 13 + 26 files changed, 1646 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/com/discdrop/client/MusicBrainzClient.java create mode 100644 src/main/java/com/discdrop/client/dto/ArtistDetailsResponse.java create mode 100644 src/main/java/com/discdrop/client/dto/ArtistSearchResponse.java create mode 100644 src/main/java/com/discdrop/client/dto/ReleaseGroupBrowseResponse.java create mode 100644 src/main/java/com/discdrop/db/DatabaseService.java create mode 100644 src/main/java/com/discdrop/db/Record.java create mode 100644 src/main/java/com/discdrop/service/ArtistService.java create mode 100644 src/main/java/com/discdrop/service/CacheService.java create mode 100644 src/main/java/com/discdrop/service/FeedService.java create mode 100644 src/main/java/com/discdrop/service/ReleaseGroupService.java create mode 100644 src/main/java/com/discdrop/util/RSSFeedBuilder.java create mode 100644 src/main/java/com/discdrop/web/ArtistController.java create mode 100644 src/main/java/com/discdrop/web/ArtistsPage.java create mode 100644 src/main/java/com/discdrop/web/FeedController.java create mode 100644 src/main/java/com/discdrop/web/ReleasesPage.java create mode 100644 src/main/java/com/discdrop/web/SettingsController.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/templates/artists.html create mode 100644 src/main/resources/templates/fragments/artist-list.html create mode 100644 src/main/resources/templates/fragments/feed-entries.html create mode 100644 src/main/resources/templates/fragments/search-results.html create mode 100644 src/main/resources/templates/fragments/settings-form.html create mode 100644 src/main/resources/templates/layouts/base.html create mode 100644 src/main/resources/templates/releases.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79fe65f --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +target/ +data/ +*.db +*.db-journal +*.db-wal +*.db-shm +*.iml +.idea/ +.vscode/ +*.class +*.jar +*.log +.DS_Store diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9de2c05 --- /dev/null +++ b/pom.xml @@ -0,0 +1,132 @@ + + 4.0.0 + + com.discdrop + discdrop + 1.0.0-SNAPSHOT + jar + + + 3.37.1 + 3.13.0 + 21 + UTF-8 + UTF-8 + 3.5.2 + + + + + + io.quarkus + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-resteasy + + + + + io.quarkus + quarkus-resteasy-qute + + + + + io.quarkus + quarkus-rest-client + + + + + io.quarkus + quarkus-arc + + + + + org.xerial + sqlite-jdbc + 3.49.1.0 + + + + + com.rometools + rome + 2.1.0 + + + + + io.quarkus + quarkus-jackson + + + io.quarkus + quarkus-rest-client-jackson + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + 5.5.1 + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + 21 + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + diff --git a/src/main/java/com/discdrop/client/MusicBrainzClient.java b/src/main/java/com/discdrop/client/MusicBrainzClient.java new file mode 100644 index 0000000..d4cfd97 --- /dev/null +++ b/src/main/java/com/discdrop/client/MusicBrainzClient.java @@ -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 + ); +} diff --git a/src/main/java/com/discdrop/client/dto/ArtistDetailsResponse.java b/src/main/java/com/discdrop/client/dto/ArtistDetailsResponse.java new file mode 100644 index 0000000..01d2f68 --- /dev/null +++ b/src/main/java/com/discdrop/client/dto/ArtistDetailsResponse.java @@ -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 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) {} +} diff --git a/src/main/java/com/discdrop/client/dto/ArtistSearchResponse.java b/src/main/java/com/discdrop/client/dto/ArtistSearchResponse.java new file mode 100644 index 0000000..6200c4f --- /dev/null +++ b/src/main/java/com/discdrop/client/dto/ArtistSearchResponse.java @@ -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 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) {} +} diff --git a/src/main/java/com/discdrop/client/dto/ReleaseGroupBrowseResponse.java b/src/main/java/com/discdrop/client/dto/ReleaseGroupBrowseResponse.java new file mode 100644 index 0000000..1c9d74f --- /dev/null +++ b/src/main/java/com/discdrop/client/dto/ReleaseGroupBrowseResponse.java @@ -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 releaseGroups, + int releaseGroupCount +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record ReleaseGroup( + String id, + String title, + String firstReleaseDate, + PrimaryType primaryType, + List secondaryTypes, + List 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) {} + } + } +} diff --git a/src/main/java/com/discdrop/db/DatabaseService.java b/src/main/java/com/discdrop/db/DatabaseService.java new file mode 100644 index 0000000..9247ce8 --- /dev/null +++ b/src/main/java/com/discdrop/db/DatabaseService.java @@ -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 findAllArtists() { + var artists = new ArrayList(); + 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 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 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 getFeed(List artistMbids, List primaryTypes) { + var groups = new ArrayList(); + 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 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"); + } +} diff --git a/src/main/java/com/discdrop/db/Record.java b/src/main/java/com/discdrop/db/Record.java new file mode 100644 index 0000000..fe12a09 --- /dev/null +++ b/src/main/java/com/discdrop/db/Record.java @@ -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 + ) {} +} diff --git a/src/main/java/com/discdrop/service/ArtistService.java b/src/main/java/com/discdrop/service/ArtistService.java new file mode 100644 index 0000000..2498e96 --- /dev/null +++ b/src/main/java/com/discdrop/service/ArtistService.java @@ -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 search(String query) { + var resp = mbClient.searchArtists( + "artist:" + query, 10, "json"); + if (resp == null || resp.artists() == null) return List.of(); + + var results = new ArrayList(); + 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 findByMbid(String mbid) { + return db.findByMbid(mbid); + } + + public List 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); + } +} diff --git a/src/main/java/com/discdrop/service/CacheService.java b/src/main/java/com/discdrop/service/CacheService.java new file mode 100644 index 0000000..57aec51 --- /dev/null +++ b/src/main/java/com/discdrop/service/CacheService.java @@ -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 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); + } +} diff --git a/src/main/java/com/discdrop/service/FeedService.java b/src/main/java/com/discdrop/service/FeedService.java new file mode 100644 index 0000000..a77d5da --- /dev/null +++ b/src/main/java/com/discdrop/service/FeedService.java @@ -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 getFeedEntries() { + var artists = artistService.findAll(); + if (artists.isEmpty()) return List.of(); + + var artistMbids = new ArrayList(); + 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(); + for (var a : artists) { + monitoredTypes.addAll(getMonitoredTypes(a)); + } + + return db.getFeed(artistMbids, monitoredTypes.isEmpty() ? allTypes : monitoredTypes); + } + + private List getMonitoredTypes(TrackedArtist a) { + var types = new ArrayList(); + 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; + } +} diff --git a/src/main/java/com/discdrop/service/ReleaseGroupService.java b/src/main/java/com/discdrop/service/ReleaseGroupService.java new file mode 100644 index 0000000..5a38f88 --- /dev/null +++ b/src/main/java/com/discdrop/service/ReleaseGroupService.java @@ -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 fetchAndCache(String artistMbid, String artistName, + List monitoredTypes) { + var allGroups = new ArrayList(); + 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; + } +} diff --git a/src/main/java/com/discdrop/util/RSSFeedBuilder.java b/src/main/java/com/discdrop/util/RSSFeedBuilder.java new file mode 100644 index 0000000..493cdfc --- /dev/null +++ b/src/main/java/com/discdrop/util/RSSFeedBuilder.java @@ -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 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(); + 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 """ + + + + DiscDrop — Release Feed + + + + + + """.formatted(baseUrl, baseUrl); + } + + private String buildDescription(ReleaseGroup rg) { + var sb = new StringBuilder(); + sb.append("\"Cover\""); + sb.append("

").append(escHtml(rg.artistName())).append("") + .append(" ").append(escHtml(rg.title())).append("
"); + sb.append("Type: ").append(escHtml(rg.primaryType())); + if (rg.secondaryTypes() != null && !rg.secondaryTypes().equals("[]")) { + sb.append(" | Secondary: ").append(escHtml(rg.secondaryTypes())); + } + sb.append("
First Release Date: ").append(rg.firstReleaseDate()); + sb.append("

"); + 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("\"", """); + } +} diff --git a/src/main/java/com/discdrop/web/ArtistController.java b/src/main/java/com/discdrop/web/ArtistController.java new file mode 100644 index 0000000..3c5d290 --- /dev/null +++ b/src/main/java/com/discdrop/web/ArtistController.java @@ -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 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 getMonitoredTypes(TrackedArtist a) { + var types = new ArrayList(); + 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; + } +} diff --git a/src/main/java/com/discdrop/web/ArtistsPage.java b/src/main/java/com/discdrop/web/ArtistsPage.java new file mode 100644 index 0000000..9049c37 --- /dev/null +++ b/src/main/java/com/discdrop/web/ArtistsPage.java @@ -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"); + } +} diff --git a/src/main/java/com/discdrop/web/FeedController.java b/src/main/java/com/discdrop/web/FeedController.java new file mode 100644 index 0000000..12a06b7 --- /dev/null +++ b/src/main/java/com/discdrop/web/FeedController.java @@ -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(); + } +} diff --git a/src/main/java/com/discdrop/web/ReleasesPage.java b/src/main/java/com/discdrop/web/ReleasesPage.java new file mode 100644 index 0000000..052ed58 --- /dev/null +++ b/src/main/java/com/discdrop/web/ReleasesPage.java @@ -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"); + } +} diff --git a/src/main/java/com/discdrop/web/SettingsController.java b/src/main/java/com/discdrop/web/SettingsController.java new file mode 100644 index 0000000..2cb1aad --- /dev/null +++ b/src/main/java/com/discdrop/web/SettingsController.java @@ -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("
Settings saved
") + .build(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..fda0816 --- /dev/null +++ b/src/main/resources/application.properties @@ -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} diff --git a/src/main/resources/templates/artists.html b/src/main/resources/templates/artists.html new file mode 100644 index 0000000..87d327f --- /dev/null +++ b/src/main/resources/templates/artists.html @@ -0,0 +1,9 @@ +{#include layouts/base} +{#page-content} +
+

🎤 Artists

+
+ +
+
+{/page-content} diff --git a/src/main/resources/templates/fragments/artist-list.html b/src/main/resources/templates/fragments/artist-list.html new file mode 100644 index 0000000..9cc40bd --- /dev/null +++ b/src/main/resources/templates/fragments/artist-list.html @@ -0,0 +1,80 @@ +{#if artists.isEmpty()} +
+

No artists tracked yet

+

Search for artists using the search bar above and click "+ Add"

+
+{:else} +
+ + + + + + + + + + + {#for artist in artists} + + + + + + + {/for} + +
ArtistSinceRelease TypesActions
+
{artist.name}
+ {#if artist.disambiguation} +
{artist.disambiguation}
+ {/if} +
{artist.createdAt} +
+ + + + + +
+
+ +
+
+{/if} diff --git a/src/main/resources/templates/fragments/feed-entries.html b/src/main/resources/templates/fragments/feed-entries.html new file mode 100644 index 0000000..74b243b --- /dev/null +++ b/src/main/resources/templates/fragments/feed-entries.html @@ -0,0 +1,37 @@ +{#if entries.isEmpty()} +
+

No releases found

+

Add artists to your collection to see their releases here

+
+{:else} +
+ + + + + + + + + + + {#for entry in entries} + + + + + + + {/for} + +
ArtistReleaseTypeDate
{entry.artistName} + + {entry.title} + + + {entry.primaryType} + {entry.firstReleaseDate}
+
+{/if} diff --git a/src/main/resources/templates/fragments/search-results.html b/src/main/resources/templates/fragments/search-results.html new file mode 100644 index 0000000..422bcfc --- /dev/null +++ b/src/main/resources/templates/fragments/search-results.html @@ -0,0 +1,31 @@ +
+ {#if artists.isEmpty()} +
No artists found
+ {:else} + {#for artist in artists} +
+
+ {artist.name} + {#if artist.disambiguation} + — {artist.disambiguation} + {/if} +
+ {artist.area} +
+
+ {#if artist.alreadyTracked} + ✓ Added + {:else} + + {/if} +
+
+ {/for} + {/if} +
diff --git a/src/main/resources/templates/fragments/settings-form.html b/src/main/resources/templates/fragments/settings-form.html new file mode 100644 index 0000000..21a1f1b --- /dev/null +++ b/src/main/resources/templates/fragments/settings-form.html @@ -0,0 +1,70 @@ +

⚙️ Settings

+
+
+ +
+ + + + +
+
+ +
+ +
+ + + + + +
+
+ + +
diff --git a/src/main/resources/templates/layouts/base.html b/src/main/resources/templates/layouts/base.html new file mode 100644 index 0000000..5f150c3 --- /dev/null +++ b/src/main/resources/templates/layouts/base.html @@ -0,0 +1,71 @@ + + + + + + DiscDrop + + + + + + + +
+ {#insert page-content}{/page-content} +
+ + + + + + diff --git a/src/main/resources/templates/releases.html b/src/main/resources/templates/releases.html new file mode 100644 index 0000000..e901684 --- /dev/null +++ b/src/main/resources/templates/releases.html @@ -0,0 +1,13 @@ +{#include layouts/base} +{#page-content} +
+

📡 Releases

+
+ +
+ +
+{/page-content}