From 5861da89b3674a8c0f2218797d7844022a354672 Mon Sep 17 00:00:00 2001 From: droideparanoico Date: Wed, 15 Jul 2026 10:34:32 +0200 Subject: [PATCH] Initial commit --- .gitignore | 25 +++ .mvn/wrapper/maven-wrapper.properties | 3 + README.md | 29 ++++ pom.xml | 157 ++++++++++++++++++ .../java/io/discdrop/StartupValidator.java | 21 +++ .../io/discdrop/mbz/MusicBrainzClient.java | 31 ++++ .../mbz/MusicBrainzHeadersFactory.java | 30 ++++ .../io/discdrop/mbz/MusicBrainzService.java | 47 ++++++ .../discdrop/mbz/dto/ArtistSearchResult.java | 23 +++ .../mbz/dto/ReleaseGroupBrowseResult.java | 39 +++++ .../io/discdrop/persistence/AppSetting.java | 36 ++++ .../persistence/ArtistTypeSetting.java | 36 ++++ .../discdrop/persistence/FollowedArtist.java | 42 +++++ .../persistence/ReleaseGroupEntity.java | 48 ++++++ .../persistence/ReleaseGroupRepository.java | 85 ++++++++++ .../io/discdrop/resource/ArtistResource.java | 116 +++++++++++++ .../io/discdrop/resource/PageResource.java | 58 +++++++ .../io/discdrop/resource/RssResource.java | 70 ++++++++ .../io/discdrop/resource/SearchResource.java | 45 +++++ .../discdrop/resource/SettingsResource.java | 61 +++++++ .../io/discdrop/service/ArtistService.java | 104 ++++++++++++ .../java/io/discdrop/service/FeedService.java | 40 +++++ .../io/discdrop/service/SettingsService.java | 52 ++++++ .../java/io/discdrop/service/SyncService.java | 99 +++++++++++ src/main/resources/application.properties | 22 +++ src/main/resources/static/css/app.css | 3 + src/main/resources/static/img/favicon.svg | 8 + src/main/resources/static/img/logo.svg | 9 + src/main/resources/static/img/no-cover.svg | 6 + src/main/resources/static/js/app.js | 72 ++++++++ src/main/resources/templates/artists.html | 46 +++++ .../templates/fragments/artist-row.html | 36 ++++ .../templates/fragments/feed-list.html | 18 ++ .../templates/fragments/feed-row.html | 22 +++ .../templates/fragments/search-dropdown.html | 39 +++++ .../templates/fragments/settings-panel.html | 39 +++++ src/main/resources/templates/index.html | 73 ++++++++ src/main/resources/templates/rss.xml | 21 +++ 38 files changed, 1711 insertions(+) create mode 100644 .gitignore create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/io/discdrop/StartupValidator.java create mode 100644 src/main/java/io/discdrop/mbz/MusicBrainzClient.java create mode 100644 src/main/java/io/discdrop/mbz/MusicBrainzHeadersFactory.java create mode 100644 src/main/java/io/discdrop/mbz/MusicBrainzService.java create mode 100644 src/main/java/io/discdrop/mbz/dto/ArtistSearchResult.java create mode 100644 src/main/java/io/discdrop/mbz/dto/ReleaseGroupBrowseResult.java create mode 100644 src/main/java/io/discdrop/persistence/AppSetting.java create mode 100644 src/main/java/io/discdrop/persistence/ArtistTypeSetting.java create mode 100644 src/main/java/io/discdrop/persistence/FollowedArtist.java create mode 100644 src/main/java/io/discdrop/persistence/ReleaseGroupEntity.java create mode 100644 src/main/java/io/discdrop/persistence/ReleaseGroupRepository.java create mode 100644 src/main/java/io/discdrop/resource/ArtistResource.java create mode 100644 src/main/java/io/discdrop/resource/PageResource.java create mode 100644 src/main/java/io/discdrop/resource/RssResource.java create mode 100644 src/main/java/io/discdrop/resource/SearchResource.java create mode 100644 src/main/java/io/discdrop/resource/SettingsResource.java create mode 100644 src/main/java/io/discdrop/service/ArtistService.java create mode 100644 src/main/java/io/discdrop/service/FeedService.java create mode 100644 src/main/java/io/discdrop/service/SettingsService.java create mode 100644 src/main/java/io/discdrop/service/SyncService.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/static/css/app.css create mode 100644 src/main/resources/static/img/favicon.svg create mode 100644 src/main/resources/static/img/logo.svg create mode 100644 src/main/resources/static/img/no-cover.svg create mode 100644 src/main/resources/static/js/app.js create mode 100644 src/main/resources/templates/artists.html create mode 100644 src/main/resources/templates/fragments/artist-row.html create mode 100644 src/main/resources/templates/fragments/feed-list.html create mode 100644 src/main/resources/templates/fragments/feed-row.html create mode 100644 src/main/resources/templates/fragments/search-dropdown.html create mode 100644 src/main/resources/templates/fragments/settings-panel.html create mode 100644 src/main/resources/templates/index.html create mode 100644 src/main/resources/templates/rss.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fdf2f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Maven +target/ +*.jar +*.war + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.project +.classpath +nbactions.xml +nb-configuration.xml + +# Quarkus dev +.mvn/wrapper/maven-wrapper.jar + +# H2 data +data/ +*.mv.db +*.trace.db + +# OS +.DS_Store diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..9edb0f9 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=http://nexus.services.odigeo.com/nexus/content/groups/public/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..14a9b8f --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# DiscDrop + +Follow MusicBrainz artists and track their **release groups** (not individual releases, to avoid duplicate vinyl/CD editions). Shows a combined web feed plus an RSS feed of new release groups, ordered by first-release-date. + +Built with Java 21 + Quarkus, htmx, and daisyUI. + +## Run (dev) + +```bash +./mvnw quarkus:dev +``` + +App at http://localhost:8080 — RSS at http://localhost:8080/rss + +## Configuration (`application.properties`) + +| Property | Default | Notes | +|---|---|---| +| `discdrop.mbz.user-agent` | `DiscDrop/1.0 (david.alvarez.81@gmail.com)` | **Required.** MusicBrainz needs a meaningful contact string. Override per environment. | +| `quarkus.rest-client."musicbrainz".url` | `https://musicbrainz.org/ws/2` | MBZ API base. | +| `discdrop.mbz.rate-limit-ms` | `1000` | Enforced gap between MBZ calls (≤1 req/s). | +| `discdrop.feed.page-size` | `25` | Feed page size / load-more batch. | +| `discdrop.rss.item-count` | `50` | RSS items. | + +The H2 file database lives in `./data/discdrop.mv.db` (gitignored). + +## Architecture + +See `../discdrop-plan.md` for the full design. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9414962 --- /dev/null +++ b/pom.xml @@ -0,0 +1,157 @@ + + + 4.0.0 + + io.discdrop + discdrop + 1.0.0-SNAPSHOT + DiscDrop + Follow MusicBrainz artists and track their release groups + + + 3.13.0 + 21 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.12.1 + true + 3.2.5 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-resteasy-reactive-qute + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkus + quarkus-rest-client + + + io.quarkus + quarkus-rest-client-jackson + + + io.quarkus + quarkus-hibernate-orm + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-h2 + + + io.quarkus + quarkus-scheduler + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-mockito + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin} + + + -parameters + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + native + + + + + + maven-failsafe-plugin + + ${skipITs} + + + + + + native + + + + diff --git a/src/main/java/io/discdrop/StartupValidator.java b/src/main/java/io/discdrop/StartupValidator.java new file mode 100644 index 0000000..c79a60a --- /dev/null +++ b/src/main/java/io/discdrop/StartupValidator.java @@ -0,0 +1,21 @@ +package io.discdrop; + +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import org.eclipse.microprofile.config.ConfigProvider; + +@ApplicationScoped +public class StartupValidator { + + void onStart(@Observes StartupEvent event) { + String ua = ConfigProvider.getConfig() + .getOptionalValue("discdrop.mbz.user-agent", String.class) + .orElse(null); + if (ua == null || ua.isBlank()) { + throw new IllegalStateException( + "Missing required configuration 'discdrop.mbz.user-agent'. " + + "MusicBrainz requires a meaningful User-Agent contact string."); + } + } +} diff --git a/src/main/java/io/discdrop/mbz/MusicBrainzClient.java b/src/main/java/io/discdrop/mbz/MusicBrainzClient.java new file mode 100644 index 0000000..8d39766 --- /dev/null +++ b/src/main/java/io/discdrop/mbz/MusicBrainzClient.java @@ -0,0 +1,31 @@ +package io.discdrop.mbz; + +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.mbz.dto.ReleaseGroupBrowseResult; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@Path("/") +@RegisterRestClient(configKey = "musicbrainz") +@RegisterClientHeaders(MusicBrainzHeadersFactory.class) +public interface MusicBrainzClient { + + @GET + @Path("/artist") + ArtistSearchResult searchArtists(@QueryParam("query") String q, + @QueryParam("limit") int limit, + @QueryParam("fmt") String fmt); + + @GET + @Path("/release-group") + ReleaseGroupBrowseResult browseReleaseGroups(@QueryParam("artist") String mbid, + @QueryParam("type") String types, + @QueryParam("limit") int limit, + @QueryParam("offset") int offset, + @QueryParam("inc") String inc, + @QueryParam("fmt") String fmt, + @QueryParam("release-group-status") String status); +} diff --git a/src/main/java/io/discdrop/mbz/MusicBrainzHeadersFactory.java b/src/main/java/io/discdrop/mbz/MusicBrainzHeadersFactory.java new file mode 100644 index 0000000..587f65f --- /dev/null +++ b/src/main/java/io/discdrop/mbz/MusicBrainzHeadersFactory.java @@ -0,0 +1,30 @@ +package io.discdrop.mbz; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.ext.Provider; +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory; + +@ApplicationScoped +@Provider +public class MusicBrainzHeadersFactory implements ClientHeadersFactory { + + private static final String USER_AGENT_KEY = "discdrop.mbz.user-agent"; + + @Override + public MultivaluedMap update(MultivaluedMap incoming, + MultivaluedMap outgoing) { + String ua = ConfigProvider.getConfig() + .getOptionalValue(USER_AGENT_KEY, String.class) + .filter(s -> s != null && !s.isBlank()) + .orElse(null); + if (ua == null) { + throw new IllegalStateException( + "Missing required configuration 'discdrop.mbz.user-agent'. " + + "MusicBrainz requires a meaningful User-Agent contact string."); + } + outgoing.putSingle("User-Agent", ua); + return outgoing; + } +} diff --git a/src/main/java/io/discdrop/mbz/MusicBrainzService.java b/src/main/java/io/discdrop/mbz/MusicBrainzService.java new file mode 100644 index 0000000..0726079 --- /dev/null +++ b/src/main/java/io/discdrop/mbz/MusicBrainzService.java @@ -0,0 +1,47 @@ +package io.discdrop.mbz; + +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.mbz.dto.ReleaseGroupBrowseResult; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import java.util.List; + +@ApplicationScoped +public class MusicBrainzService { + + @Inject + @RestClient + MusicBrainzClient client; + + @ConfigProperty(name = "discdrop.mbz.rate-limit-ms", defaultValue = "1000") + long rateLimitMs; + + private volatile long lastCallMillis = 0L; + + public synchronized void acquire() { + long elapsed = System.currentTimeMillis() - lastCallMillis; + if (elapsed < rateLimitMs) { + try { + Thread.sleep(rateLimitMs - elapsed); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + lastCallMillis = System.currentTimeMillis(); + } + + public List searchArtists(String query, int limit) { + acquire(); + ArtistSearchResult result = client.searchArtists(query, limit, "json"); + return result != null && result.artists != null ? result.artists : List.of(); + } + + public ReleaseGroupBrowseResult browseReleaseGroups(String mbid, String types, int limit, int offset) { + acquire(); + return client.browseReleaseGroups( + mbid, types, limit, offset, "artist-credits", "json", "website-default"); + } +} diff --git a/src/main/java/io/discdrop/mbz/dto/ArtistSearchResult.java b/src/main/java/io/discdrop/mbz/dto/ArtistSearchResult.java new file mode 100644 index 0000000..ca3ec89 --- /dev/null +++ b/src/main/java/io/discdrop/mbz/dto/ArtistSearchResult.java @@ -0,0 +1,23 @@ +package io.discdrop.mbz.dto; + +import java.util.List; + +public class ArtistSearchResult { + public int count; + public int offset; + public List artists; + + public static class ArtistDto { + public String id; + public String name; + public String disambiguation; + public String type; + public String country; + public String score; + public Area area; + + public static class Area { + public String name; + } + } +} diff --git a/src/main/java/io/discdrop/mbz/dto/ReleaseGroupBrowseResult.java b/src/main/java/io/discdrop/mbz/dto/ReleaseGroupBrowseResult.java new file mode 100644 index 0000000..ef3d18c --- /dev/null +++ b/src/main/java/io/discdrop/mbz/dto/ReleaseGroupBrowseResult.java @@ -0,0 +1,39 @@ +package io.discdrop.mbz.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public class ReleaseGroupBrowseResult { + @JsonProperty("release-group-count") + public int count; + + @JsonProperty("release-group-offset") + public int offset; + + @JsonProperty("release-groups") + public List releaseGroups; + + public static class ReleaseGroupDto { + public String id; + public String title; + @JsonProperty("first-release-date") + public String firstReleaseDate; + @JsonProperty("primary-type") + public String primaryType; + @JsonProperty("secondary-types") + public List secondaryTypes; + @JsonProperty("artist-credit") + public List artistCredit; + + public static class ArtistCredit { + public String name; + public String joinphrase; + public Artist artist; + + public static class Artist { + public String id; + public String name; + } + } + } +} diff --git a/src/main/java/io/discdrop/persistence/AppSetting.java b/src/main/java/io/discdrop/persistence/AppSetting.java new file mode 100644 index 0000000..ee8a5a9 --- /dev/null +++ b/src/main/java/io/discdrop/persistence/AppSetting.java @@ -0,0 +1,36 @@ +package io.discdrop.persistence; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table +public class AppSetting extends PanacheEntity { + + @Column(name = "setting_key", nullable = false, unique = true) + public String key; + + @Column(name = "setting_value", nullable = false, columnDefinition = "TEXT") + public String value; + + public static AppSetting find(String key) { + return find("key", key).firstResult(); + } + + public static String get(String key, String defaultValue) { + AppSetting s = find(key); + return s != null ? s.value : defaultValue; + } + + public static void set(String key, String value) { + AppSetting s = find(key); + if (s == null) { + s = new AppSetting(); + s.key = key; + s.persist(); + } + s.value = value; + } +} diff --git a/src/main/java/io/discdrop/persistence/ArtistTypeSetting.java b/src/main/java/io/discdrop/persistence/ArtistTypeSetting.java new file mode 100644 index 0000000..0bb1d2c --- /dev/null +++ b/src/main/java/io/discdrop/persistence/ArtistTypeSetting.java @@ -0,0 +1,36 @@ +package io.discdrop.persistence; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(uniqueConstraints = @UniqueConstraint( + name = "uk_artist_primary_type", columnNames = { "artist_id", "primary_type" } +)) +public class ArtistTypeSetting extends PanacheEntity { + + @ManyToOne(optional = false) + @JoinColumn(name = "artist_id", nullable = false, + foreignKey = @ForeignKey(name = "fk_type_setting_artist")) + public FollowedArtist artist; + + @Column(name = "primary_type", nullable = false) + public String primaryType; + + @Column(nullable = false) + public boolean enabled; + + public static long deleteByArtist(FollowedArtist artist) { + return delete("artist", artist); + } + + public static java.util.List findByArtist(FollowedArtist artist) { + return list("artist", artist); + } +} diff --git a/src/main/java/io/discdrop/persistence/FollowedArtist.java b/src/main/java/io/discdrop/persistence/FollowedArtist.java new file mode 100644 index 0000000..94ea917 --- /dev/null +++ b/src/main/java/io/discdrop/persistence/FollowedArtist.java @@ -0,0 +1,42 @@ +package io.discdrop.persistence; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table +public class FollowedArtist extends PanacheEntity { + + @Column(nullable = false, unique = true) + public String mbid; + + @Column(nullable = false) + public String name; + + public String sortName; + + public String disambiguation; + + public String areaName; + + public String type; + + public String country; + + @Column(nullable = false) + public Instant followedAt; + + public Instant lastSyncedAt; + + public static FollowedArtist findByMbid(String mbid) { + return find("mbid", mbid).firstResult(); + } + + public static boolean existsByMbid(String mbid) { + return count("mbid", mbid) > 0; + } +} diff --git a/src/main/java/io/discdrop/persistence/ReleaseGroupEntity.java b/src/main/java/io/discdrop/persistence/ReleaseGroupEntity.java new file mode 100644 index 0000000..7078575 --- /dev/null +++ b/src/main/java/io/discdrop/persistence/ReleaseGroupEntity.java @@ -0,0 +1,48 @@ +package io.discdrop.persistence; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table +public class ReleaseGroupEntity extends PanacheEntity { + + @Column(nullable = false, unique = true) + public String mbid; + + @ManyToOne(optional = false) + @JoinColumn(name = "artist_id", nullable = false, + foreignKey = @ForeignKey(name = "fk_rg_artist")) + public FollowedArtist artist; + + @Column(nullable = false) + public String title; + + public LocalDate firstReleaseDate; + + public String firstReleaseDateRaw; + + public String primaryType; + + public String secondaryTypes; + + @Column(nullable = false) + public String mbzUrl; + + public Instant discoveredAt; + + public static ReleaseGroupEntity findByMbid(String mbid) { + return find("mbid", mbid).firstResult(); + } + + public static long deleteByArtist(FollowedArtist artist) { + return delete("artist", artist); + } +} diff --git a/src/main/java/io/discdrop/persistence/ReleaseGroupRepository.java b/src/main/java/io/discdrop/persistence/ReleaseGroupRepository.java new file mode 100644 index 0000000..d043913 --- /dev/null +++ b/src/main/java/io/discdrop/persistence/ReleaseGroupRepository.java @@ -0,0 +1,85 @@ +package io.discdrop.persistence; + +import io.discdrop.mbz.dto.ReleaseGroupBrowseResult; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; + +@ApplicationScoped +public class ReleaseGroupRepository { + + @Transactional + public FollowedArtist findArtist(String mbid) { + return FollowedArtist.findByMbid(mbid); + } + + @Transactional + public void upsertPage(String mbid, List dtos) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist == null) { + return; + } + for (ReleaseGroupBrowseResult.ReleaseGroupDto rg : dtos) { + upsertOne(artist, rg); + } + } + + @Transactional + public void markSynced(String mbid) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist != null) { + artist.lastSyncedAt = Instant.now(); + } + } + + @Transactional + public void deleteByArtistMbid(String mbid) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist != null) { + ReleaseGroupEntity.deleteByArtist(artist); + } + } + + private void upsertOne(FollowedArtist artist, ReleaseGroupBrowseResult.ReleaseGroupDto rg) { + ReleaseGroupEntity entity = ReleaseGroupEntity.findByMbid(rg.id); + boolean isNew = entity == null; + if (isNew) { + entity = new ReleaseGroupEntity(); + entity.mbid = rg.id; + entity.artist = artist; + entity.discoveredAt = Instant.now(); + } + entity.title = rg.title; + entity.firstReleaseDateRaw = rg.firstReleaseDate; + entity.firstReleaseDate = parseDate(rg.firstReleaseDate); + entity.primaryType = rg.primaryType; + entity.secondaryTypes = rg.secondaryTypes != null ? String.join(", ", rg.secondaryTypes) : null; + entity.mbzUrl = "https://musicbrainz.org/release-group/" + rg.id; + if (isNew) { + entity.persist(); + } + } + + private LocalDate parseDate(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + if (raw.length() >= 10) { + return LocalDate.parse(raw.substring(0, 10), DateTimeFormatter.ISO_LOCAL_DATE); + } else if (raw.length() >= 7) { + return LocalDate.parse(raw + "-01", DateTimeFormatter.ISO_LOCAL_DATE); + } else if (raw.length() >= 4) { + return LocalDate.parse(raw + "-01-01", DateTimeFormatter.ISO_LOCAL_DATE); + } + } catch (DateTimeParseException e) { + return null; + } + return null; + } +} diff --git a/src/main/java/io/discdrop/resource/ArtistResource.java b/src/main/java/io/discdrop/resource/ArtistResource.java new file mode 100644 index 0000000..148a17c --- /dev/null +++ b/src/main/java/io/discdrop/resource/ArtistResource.java @@ -0,0 +1,116 @@ +package io.discdrop.resource; + +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.persistence.ArtistTypeSetting; +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.persistence.ReleaseGroupEntity; +import io.discdrop.service.ArtistService; +import io.discdrop.service.FeedService; +import io.discdrop.service.SyncService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +import java.util.List; + +@Path("/artists") +public class ArtistResource { + + @Inject + ArtistService artistService; + + @Inject + SyncService syncService; + + @Inject + FeedService feedService; + + @Inject + @Location("fragments/feed-list.html") + Template fragments_feed_list; + + @Inject + @Location("fragments/artist-row.html") + Template fragments_artist_row; + + @Inject + Template artists; + + @POST + @Path("/follow") + public TemplateInstance follow(@FormParam("mbid") String mbid, + @FormParam("name") String name, + @FormParam("disambiguation") String disambiguation, + @FormParam("area") String area, + @FormParam("type") String type, + @FormParam("country") String country) { + ArtistSearchResult.ArtistDto dto = new ArtistSearchResult.ArtistDto(); + dto.id = mbid; + dto.name = name; + dto.disambiguation = disambiguation; + dto.type = type; + dto.country = country; + if (area != null && !area.isBlank()) { + dto.area = new ArtistSearchResult.ArtistDto.Area(); + dto.area.name = area; + } + FollowedArtist artist = artistService.follow(dto); + syncService.syncArtist(artist.mbid); + return feedFragment(0); + } + + @DELETE + @Path("/{mbid}") + public TemplateInstance unfollow(@PathParam("mbid") String mbid) { + artistService.unfollow(mbid); + return feedFragment(0); + } + + @GET + public TemplateInstance artistsPage() { + List artistsList = artistService.allFollowed(); + return artists.data("artists", artistsList); + } + + @GET + @Path("/{mbid}/row") + public TemplateInstance artistRow(@PathParam("mbid") String mbid) { + FollowedArtist artist = artistService.findArtist(mbid); + List settings = artist != null ? artistService.settingsFor(artist) : List.of(); + return fragments_artist_row.data("artist", artist) + .data("settings", settings); + } + + @POST + @Path("/{mbid}/types") + public TemplateInstance toggleType(@PathParam("mbid") String mbid, + @FormParam("primaryType") String primaryType, + @FormParam("enabled") boolean enabled) { + FollowedArtist artist = artistService.findArtist(mbid); + if (artist != null) { + artistService.setTypeEnabled(artist, primaryType, enabled); + syncService.resyncArtist(mbid); + } + return feedFragment(0); + } + + private TemplateInstance feedFragment(int offset) { + if (offset < 0) { + offset = 0; + } + List rows = feedService.feedPage(offset); + int nextOffset = offset + rows.size(); + boolean hasMore = rows.size() == feedService.pageSize(); + return fragments_feed_list.data("rows", rows) + .data("nextOffset", nextOffset) + .data("hasMore", hasMore) + .data("pageSize", feedService.pageSize()); + } +} diff --git a/src/main/java/io/discdrop/resource/PageResource.java b/src/main/java/io/discdrop/resource/PageResource.java new file mode 100644 index 0000000..686d43a --- /dev/null +++ b/src/main/java/io/discdrop/resource/PageResource.java @@ -0,0 +1,58 @@ +package io.discdrop.resource; + +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.persistence.ReleaseGroupEntity; +import io.discdrop.service.FeedService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import java.util.List; + +@Path("/") +public class PageResource { + + @Inject + FeedService feedService; + + @Inject + Template index; + + @Inject + @Location("fragments/feed-list.html") + Template fragments_feed_list; + + @Path("/feed") + @GET + @Transactional + public TemplateInstance feedFragment(@QueryParam("offset") int offset) { + if (offset < 0) { + offset = 0; + } + List rows = feedService.feedPage(offset); + int nextOffset = offset + rows.size(); + boolean hasMore = rows.size() == feedService.pageSize(); + return fragments_feed_list.data("rows", rows) + .data("nextOffset", nextOffset) + .data("hasMore", hasMore) + .data("pageSize", feedService.pageSize()); + } + + @GET + @Transactional + public TemplateInstance index() { + List rows = feedService.feedPage(0); + boolean hasMore = rows.size() == feedService.pageSize(); + return index.data("rows", rows) + .data("nextOffset", rows.size()) + .data("hasMore", hasMore) + .data("pageSize", feedService.pageSize()) + .data("followedCount", FollowedArtist.count()) + .data("feedCount", ReleaseGroupEntity.count()); + } +} diff --git a/src/main/java/io/discdrop/resource/RssResource.java b/src/main/java/io/discdrop/resource/RssResource.java new file mode 100644 index 0000000..8781fd5 --- /dev/null +++ b/src/main/java/io/discdrop/resource/RssResource.java @@ -0,0 +1,70 @@ +package io.discdrop.resource; + +import io.discdrop.persistence.ReleaseGroupEntity; +import io.discdrop.service.FeedService; +import io.quarkus.qute.Location; +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; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Path("/rss") +public class RssResource { + + private static final DateTimeFormatter RFC822 = DateTimeFormatter.RFC_1123_DATE_TIME; + + @Inject + FeedService feedService; + + @Inject + @Location("rss.xml") + Template rss; + + @ConfigProperty(name = "discdrop.rss.item-count", defaultValue = "50") + int itemCount; + + public record RssItem(String title, String link, String guid, String pubDate, + String description, String mediaUrl) {} + + @GET + @Produces(MediaType.APPLICATION_XML) + public TemplateInstance feed() { + List entities = feedService.latest(itemCount); + List items = new ArrayList<>(entities.size()); + for (ReleaseGroupEntity e : entities) { + String pubDate = null; + if (e.firstReleaseDate != null) { + pubDate = e.firstReleaseDate.atStartOfDay(ZoneOffset.UTC).format(RFC822); + } + StringBuilder desc = new StringBuilder(); + if (e.primaryType != null) { + desc.append("Type: ").append(e.primaryType); + } + if (e.secondaryTypes != null) { + desc.append(", ").append(e.secondaryTypes); + } + desc.append("\nReleased: ").append(e.firstReleaseDateRaw != null ? e.firstReleaseDateRaw : "unknown"); + items.add(new RssItem( + e.artist.name + " – " + e.title, + e.mbzUrl, + e.mbzUrl, + pubDate, + desc.toString(), + "https://coverartarchive.org/release-group/" + e.mbid + "/front" + )); + } + return rss.data("items", items) + .data("buildDate", OffsetDateTime.now(ZoneOffset.UTC).format(RFC822)); + } +} diff --git a/src/main/java/io/discdrop/resource/SearchResource.java b/src/main/java/io/discdrop/resource/SearchResource.java new file mode 100644 index 0000000..6a2643b --- /dev/null +++ b/src/main/java/io/discdrop/resource/SearchResource.java @@ -0,0 +1,45 @@ +package io.discdrop.resource; + +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.service.ArtistService; +import io.quarkus.qute.Location; +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.QueryParam; + +import java.util.List; + +@Path("/search") +public class SearchResource { + + @Inject + ArtistService artistService; + + @Inject + @Location("fragments/search-dropdown.html") + Template fragments_search_dropdown; + + @Path("/artists") + @GET + public TemplateInstance searchArtists(@QueryParam("q") String q) { + List results = List.of(); + boolean tooShort = q == null || q.trim().length() < 2; + if (!tooShort) { + try { + results = artistService.search(q.trim()); + } catch (Exception e) { + results = List.of(); + } + } + List followedMbids = FollowedArtist.streamAll() + .map(a -> a.mbid) + .toList(); + return fragments_search_dropdown.data("results", results) + .data("followedMbids", followedMbids) + .data("query", q); + } +} diff --git a/src/main/java/io/discdrop/resource/SettingsResource.java b/src/main/java/io/discdrop/resource/SettingsResource.java new file mode 100644 index 0000000..6c3f0e3 --- /dev/null +++ b/src/main/java/io/discdrop/resource/SettingsResource.java @@ -0,0 +1,61 @@ +package io.discdrop.resource; + +import io.discdrop.persistence.AppSetting; +import io.discdrop.service.SettingsService; +import io.discdrop.service.SyncService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; + +import java.util.List; +import java.util.Set; + +@Path("/settings") +public class SettingsResource { + + @Inject + SettingsService settingsService; + + @Inject + SyncService syncService; + + @Inject + @Location("fragments/settings-panel.html") + Template fragments_settings_panel; + + private static final List SCHEDULE_OPTIONS = List.of(6, 12, 24); + + @GET + public TemplateInstance panel() { + return buildPanel(null); + } + + @POST + public TemplateInstance save(@FormParam("defaultPrimaryTypes") Set defaultPrimaryTypes, + @FormParam("syncScheduleHours") int syncScheduleHours) { + if (defaultPrimaryTypes == null) { + defaultPrimaryTypes = Set.of(); + } + settingsService.setDefaultPrimaryTypes(defaultPrimaryTypes); + int previous = settingsService.getSyncScheduleHours(); + settingsService.setSyncScheduleHours(syncScheduleHours); + if (previous != syncScheduleHours) { + syncService.reschedule(); + } + return buildPanel("saved"); + } + + private TemplateInstance buildPanel(String flash) { + return fragments_settings_panel + .data("defaultPrimaryTypes", settingsService.getDefaultPrimaryTypes()) + .data("syncScheduleHours", settingsService.getSyncScheduleHours()) + .data("allPrimaryTypes", SettingsService.ALL_PRIMARY_TYPES) + .data("scheduleOptions", SCHEDULE_OPTIONS) + .data("flash", flash); + } +} diff --git a/src/main/java/io/discdrop/service/ArtistService.java b/src/main/java/io/discdrop/service/ArtistService.java new file mode 100644 index 0000000..0a055a4 --- /dev/null +++ b/src/main/java/io/discdrop/service/ArtistService.java @@ -0,0 +1,104 @@ +package io.discdrop.service; + +import io.discdrop.mbz.MusicBrainzService; +import io.discdrop.mbz.dto.ArtistSearchResult; +import io.discdrop.persistence.ArtistTypeSetting; +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.persistence.ReleaseGroupEntity; +import io.quarkus.panache.common.Sort; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@ApplicationScoped +public class ArtistService { + + @Inject + SettingsService settingsService; + + @Inject + MusicBrainzService mbzService; + + @Transactional + public FollowedArtist follow(ArtistSearchResult.ArtistDto dto) { + if (FollowedArtist.existsByMbid(dto.id)) { + return FollowedArtist.findByMbid(dto.id); + } + FollowedArtist artist = new FollowedArtist(); + artist.mbid = dto.id; + artist.name = dto.name; + artist.sortName = dto.name; + artist.disambiguation = dto.disambiguation; + artist.areaName = dto.area != null ? dto.area.name : null; + artist.type = dto.type; + artist.country = dto.country; + artist.followedAt = Instant.now(); + artist.persist(); + + Set defaults = settingsService.getDefaultPrimaryTypes(); + for (String type : SettingsService.ALL_PRIMARY_TYPES) { + ArtistTypeSetting s = new ArtistTypeSetting(); + s.artist = artist; + s.primaryType = type; + s.enabled = defaults.contains(type); + s.persist(); + } + return artist; + } + + @Transactional + public void unfollow(String mbid) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist == null) { + return; + } + ArtistTypeSetting.deleteByArtist(artist); + ReleaseGroupEntity.deleteByArtist(artist); + artist.delete(); + } + + @Transactional + public void setTypeEnabled(FollowedArtist artist, String primaryType, boolean enabled) { + for (ArtistTypeSetting s : ArtistTypeSetting.findByArtist(artist)) { + if (s.primaryType.equalsIgnoreCase(primaryType)) { + s.enabled = enabled; + } + } + } + + @Transactional + public FollowedArtist findArtist(String mbid) { + return FollowedArtist.findByMbid(mbid); + } + + @Transactional + public Set enabledTypes(String mbid) { + FollowedArtist artist = FollowedArtist.findByMbid(mbid); + if (artist == null) { + return Set.of(); + } + return ArtistTypeSetting.findByArtist(artist).stream() + .filter(s -> s.enabled) + .map(s -> s.primaryType) + .collect(Collectors.toSet()); + } + + @Transactional + public List allFollowed() { + return FollowedArtist.listAll(Sort.by("name")); + } + + @Transactional + public List settingsFor(FollowedArtist artist) { + return ArtistTypeSetting.findByArtist(artist); + } + + public List search(String query) { + return mbzService.searchArtists(query, 10); + } +} diff --git a/src/main/java/io/discdrop/service/FeedService.java b/src/main/java/io/discdrop/service/FeedService.java new file mode 100644 index 0000000..4573205 --- /dev/null +++ b/src/main/java/io/discdrop/service/FeedService.java @@ -0,0 +1,40 @@ +package io.discdrop.service; + +import io.discdrop.persistence.ReleaseGroupEntity; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.List; + +@ApplicationScoped +public class FeedService { + + @ConfigProperty(name = "discdrop.feed.page-size", defaultValue = "25") + int pageSize; + + private static final String ORDER_HQL = + "ORDER BY firstReleaseDate DESC NULLS LAST, discoveredAt DESC"; + + private PanacheQuery orderedQuery() { + return ReleaseGroupEntity.find(ORDER_HQL); + } + + @Transactional + public List feedPage(int offset) { + if (offset < 0) { + offset = 0; + } + return orderedQuery().range(offset, offset + pageSize - 1).list(); + } + + @Transactional + public List latest(int count) { + return orderedQuery().range(0, Math.max(0, count - 1)).list(); + } + + public int pageSize() { + return pageSize; + } +} diff --git a/src/main/java/io/discdrop/service/SettingsService.java b/src/main/java/io/discdrop/service/SettingsService.java new file mode 100644 index 0000000..5a4e38e --- /dev/null +++ b/src/main/java/io/discdrop/service/SettingsService.java @@ -0,0 +1,52 @@ +package io.discdrop.service; + +import io.discdrop.persistence.AppSetting; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@ApplicationScoped +public class SettingsService { + + public static final String KEY_DEFAULT_PRIMARY_TYPES = "defaultPrimaryTypes"; + public static final String KEY_SYNC_SCHEDULE_HOURS = "syncScheduleHours"; + + public static final List ALL_PRIMARY_TYPES = List.of("album", "single", "ep", "broadcast", "other"); + + public Set getDefaultPrimaryTypes() { + String raw = AppSetting.get(KEY_DEFAULT_PRIMARY_TYPES, "album"); + return parseTypes(raw); + } + + public void setDefaultPrimaryTypes(Set types) { + AppSetting.set(KEY_DEFAULT_PRIMARY_TYPES, String.join(",", types)); + } + + public int getSyncScheduleHours() { + String raw = AppSetting.get(KEY_SYNC_SCHEDULE_HOURS, "24"); + try { + return Integer.parseInt(raw); + } catch (NumberFormatException e) { + return 24; + } + } + + public void setSyncScheduleHours(int hours) { + AppSetting.set(KEY_SYNC_SCHEDULE_HOURS, String.valueOf(hours)); + } + + private Set parseTypes(String raw) { + if (raw == null || raw.isBlank()) { + return new LinkedHashSet<>(); + } + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(String::toLowerCase) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } +} diff --git a/src/main/java/io/discdrop/service/SyncService.java b/src/main/java/io/discdrop/service/SyncService.java new file mode 100644 index 0000000..ee2ee78 --- /dev/null +++ b/src/main/java/io/discdrop/service/SyncService.java @@ -0,0 +1,99 @@ +package io.discdrop.service; + +import io.discdrop.mbz.MusicBrainzService; +import io.discdrop.mbz.dto.ReleaseGroupBrowseResult; +import io.discdrop.persistence.FollowedArtist; +import io.discdrop.persistence.ReleaseGroupRepository; +import io.quarkus.runtime.StartupEvent; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@ApplicationScoped +public class SyncService { + + @Inject + MusicBrainzService mbzService; + + @Inject + ReleaseGroupRepository repo; + + @Inject + ArtistService artistService; + + @Inject + SettingsService settingsService; + + private final ScheduledExecutorService executor = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "discdrop-sync"); + t.setDaemon(true); + return t; + }); + + private volatile ScheduledFuture currentTask; + + void onStart(@Observes StartupEvent event) { + reschedule(); + } + + @PreDestroy + void shutdown() { + executor.shutdownNow(); + } + + public synchronized void reschedule() { + if (currentTask != null) { + currentTask.cancel(false); + } + int hours = settingsService.getSyncScheduleHours(); + long period = TimeUnit.HOURS.toSeconds(hours); + currentTask = executor.scheduleAtFixedRate( + this::runSyncSafe, period, period, TimeUnit.SECONDS); + } + + void runSyncSafe() { + try { + runSync(); + } catch (Exception e) { + // keep the scheduler alive across failures + } + } + + public void runSync() { + for (FollowedArtist artist : artistService.allFollowed()) { + syncArtist(artist.mbid); + } + } + + public void syncArtist(String mbid) { + Set enabledTypes = artistService.enabledTypes(mbid); + String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes); + + int limit = 100; + int offset = 0; + boolean more = true; + while (more) { + ReleaseGroupBrowseResult page = mbzService.browseReleaseGroups(mbid, typeFilter, limit, offset); + if (page == null || page.releaseGroups == null || page.releaseGroups.isEmpty()) { + break; + } + repo.upsertPage(mbid, page.releaseGroups); + offset += page.releaseGroups.size(); + more = page.releaseGroups.size() == limit && offset < page.count; + } + repo.markSynced(mbid); + } + + public void resyncArtist(String mbid) { + repo.deleteByArtistMbid(mbid); + syncArtist(mbid); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..aaf5ab8 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,22 @@ +quarkus.application.name=DiscDrop +quarkus.http.port=8080 + +# H2 file DB +quarkus.datasource.db-kind=h2 +quarkus.datasource.jdbc.url=jdbc:h2:file:./data/discdrop;DB_CLOSE_DELAY=-1 +quarkus.datasource.username=sa +quarkus.datasource.password= +quarkus.hibernate-orm.database.generation=update + +# MusicBrainz REST client (configKey="musicbrainz") +quarkus.rest-client."musicbrainz".url=https://musicbrainz.org/ws/2 +# User-Agent (MBZ requires a meaningful contact string) - override per environment, never hardcode in code +discdrop.mbz.user-agent=DiscDrop/1.0 (david.alvarez.81@gmail.com) +discdrop.mbz.rate-limit-ms=1000 +discdrop.feed.page-size=25 +discdrop.rss.item-count=50 + +# Sync schedule default (hours): 6, 12, 24 (changeable at runtime via Settings) +discdrop.sync.default-schedule-hours=24 +# Default primary types for newly followed artists (comma list) +discdrop.sync.default-primary-types=album diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css new file mode 100644 index 0000000..0f2d769 --- /dev/null +++ b/src/main/resources/static/css/app.css @@ -0,0 +1,3 @@ +/* DiscDrop custom styles */ +html { scroll-behavior: smooth; } +#search-dropdown:empty { display: none; } diff --git a/src/main/resources/static/img/favicon.svg b/src/main/resources/static/img/favicon.svg new file mode 100644 index 0000000..3bbbd91 --- /dev/null +++ b/src/main/resources/static/img/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/static/img/logo.svg b/src/main/resources/static/img/logo.svg new file mode 100644 index 0000000..a807e14 --- /dev/null +++ b/src/main/resources/static/img/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/static/img/no-cover.svg b/src/main/resources/static/img/no-cover.svg new file mode 100644 index 0000000..050c205 --- /dev/null +++ b/src/main/resources/static/img/no-cover.svg @@ -0,0 +1,6 @@ + + + + + no art + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..cc5d1c9 --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,72 @@ +window.DiscDrop = (function () { + const THEMES = ['dark', 'light']; + const STORAGE_KEY = 'discdrop-theme'; + + function init() { + const saved = localStorage.getItem(STORAGE_KEY) || 'dark'; + applyTheme(saved); + } + + function applyTheme(theme) { + if (!THEMES.includes(theme)) { + theme = 'dark'; + } + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(STORAGE_KEY, theme); + const moon = document.getElementById('theme-icon-moon'); + const sun = document.getElementById('theme-icon-sun'); + if (moon && sun) { + moon.classList.toggle('hidden', theme === 'light'); + sun.classList.toggle('hidden', theme === 'dark'); + } + } + + function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + applyTheme(current === 'dark' ? 'light' : 'dark'); + } + + function clearSearch() { + const dropdown = document.getElementById('search-dropdown'); + const input = document.getElementById('search-input'); + const form = document.getElementById('search-form'); + if (dropdown) { + dropdown.innerHTML = ''; + } + if (input) { + input.value = ''; + } + if (form) { + form.reset(); + } + } + + function refreshFeed() { + if (typeof htmx !== 'undefined' && document.getElementById('feed-list')) { + htmx.ajax('GET', '/feed?offset=0', { target: '#feed-list', swap: 'innerHTML' }); + } + } + + function closeOnClickOutside(event) { + const searchWrap = document.getElementById('search-wrap'); + if (searchWrap && !searchWrap.contains(event.target)) { + const dropdown = document.getElementById('search-dropdown'); + if (dropdown) { + dropdown.innerHTML = ''; + } + } + const settingsDropdown = document.getElementById('settings-dropdown'); + if (settingsDropdown && !settingsDropdown.contains(event.target)) { + settingsDropdown.classList.remove('dropdown-open'); + } + } + + document.addEventListener('click', closeOnClickOutside); + document.addEventListener('DOMContentLoaded', init); + + return { + toggleTheme: toggleTheme, + clearSearch: clearSearch, + refreshFeed: refreshFeed + }; +})(); diff --git a/src/main/resources/templates/artists.html b/src/main/resources/templates/artists.html new file mode 100644 index 0000000..ea74d84 --- /dev/null +++ b/src/main/resources/templates/artists.html @@ -0,0 +1,46 @@ + + + + + + DiscDrop – Artists + + + + + + + + +
+ +
+ +
+

Followed Artists

+ {#if artists.empty} +
+

You're not following any artists yet.

+ Go to the feed to search and follow artists. +
+ {#else} + {#for artist in artists} +
+
+
+ {/for} + {/if} +
+ + diff --git a/src/main/resources/templates/fragments/artist-row.html b/src/main/resources/templates/fragments/artist-row.html new file mode 100644 index 0000000..aea5372 --- /dev/null +++ b/src/main/resources/templates/fragments/artist-row.html @@ -0,0 +1,36 @@ +
+
+
+
{artist.name}
+
+ {#if artist.disambiguation??}{artist.disambiguation}{#else}Artist{/if} + {#if artist.areaName??}{artist.areaName}{/if} +
+
+ +
+ +
+ {#for s in settings} + + {/for} +
+
diff --git a/src/main/resources/templates/fragments/feed-list.html b/src/main/resources/templates/fragments/feed-list.html new file mode 100644 index 0000000..8002a3c --- /dev/null +++ b/src/main/resources/templates/fragments/feed-list.html @@ -0,0 +1,18 @@ +{#for row in rows} +{#include fragments/feed-row.html row=row /} +{/for} +{#if hasMore} + +{/if} +{#if !hasMore && rows.empty} +
+

No releases yet.

+

Search for an artist above and click Follow to start tracking their release groups.

+
+{/if} diff --git a/src/main/resources/templates/fragments/feed-row.html b/src/main/resources/templates/fragments/feed-row.html new file mode 100644 index 0000000..2af9ba3 --- /dev/null +++ b/src/main/resources/templates/fragments/feed-row.html @@ -0,0 +1,22 @@ +
+ cover + +
+
+ {row.artist.name} + {#if row.primaryType??}{row.primaryType}{/if} + {#if row.secondaryTypes??}{row.secondaryTypes}{/if} +
+ + {row.title} + +
+ +
+ {#if row.firstReleaseDateRaw??}{row.firstReleaseDateRaw}{#else}—{/if} +
+
diff --git a/src/main/resources/templates/fragments/search-dropdown.html b/src/main/resources/templates/fragments/search-dropdown.html new file mode 100644 index 0000000..87bbd76 --- /dev/null +++ b/src/main/resources/templates/fragments/search-dropdown.html @@ -0,0 +1,39 @@ +{#if results.empty} +{#if query?? && query.length() >= 2} +
No artists found for "{query}".
+{/if} +{/if} +{#for a in results} +
+
+
{a.name}
+
+ {#if a.disambiguation??}{a.disambiguation}{#else}Artist{/if} + {#if a.area?? && a.area.name??}{a.area.name}{/if} +
+
+ {#if followedMbids.contains(a.id)} +
+ +
+ {#else} +
+ + + + + + {#if a.area?? && a.area.name??} + + {/if} + +
+ {/if} +
+{/for} diff --git a/src/main/resources/templates/fragments/settings-panel.html b/src/main/resources/templates/fragments/settings-panel.html new file mode 100644 index 0000000..b64b69e --- /dev/null +++ b/src/main/resources/templates/fragments/settings-panel.html @@ -0,0 +1,39 @@ +
+

Settings

+ + {#if flash == "saved"} +
Saved
+ {/if} + +
+
+
Default primary types for new artists
+
+ {#for t in allPrimaryTypes} + + {/for} +
+
+ +
+
Feed update schedule
+
+ {#for h in scheduleOptions} + + {/for} +
+
+ + +
+
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..ce18d1e --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,73 @@ + + + + + + DiscDrop + + + + + + + + + +
+ +
+ +
+
+
+ {#include fragments/feed-list.html rows=rows nextOffset=nextOffset hasMore=hasMore pageSize=pageSize /} +
+
+
+ + diff --git a/src/main/resources/templates/rss.xml b/src/main/resources/templates/rss.xml new file mode 100644 index 0000000..e8927f4 --- /dev/null +++ b/src/main/resources/templates/rss.xml @@ -0,0 +1,21 @@ + + + + DiscDrop – New Releases + http://localhost:8080/ + Release groups from the MusicBrainz artists you follow on DiscDrop. + {buildDate} + {#for item in items} + + {item.title} + {item.link} + {item.guid} + {#if item.pubDate??} + {item.pubDate} + {/if} + + + + {/for} + +